优化审批工具
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Weiming
2026-06-27 11:15:59 +08:00
parent 4e924327ab
commit 9805857a60
4 changed files with 110 additions and 12 deletions

View File

@@ -28,7 +28,7 @@ node server.js
{ {
"projectId": "approval_of_design", "projectId": "approval_of_design",
"port": 8080, "port": 8080,
"host": "0.0.0.0", "host": "127.0.0.1",
"label": "飞书审批配置服务" "label": "飞书审批配置服务"
} }
``` ```

View File

@@ -8,7 +8,7 @@ User=root
WorkingDirectory=/opt/feishu-approval WorkingDirectory=/opt/feishu-approval
Environment=NODE_ENV=production Environment=NODE_ENV=production
Environment=PORT=8080 Environment=PORT=8080
Environment=HOST=0.0.0.0 Environment=HOST=127.0.0.1
ExecStart=/usr/bin/node server.js ExecStart=/usr/bin/node server.js
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5

View File

@@ -1,6 +1,6 @@
{ {
"projectId": "approval_of_design", "projectId": "approval_of_design",
"port": 8080, "port": 8080,
"host": "0.0.0.0", "host": "127.0.0.1",
"label": "飞书审批配置服务" "label": "飞书审批配置服务"
} }

View File

@@ -98,6 +98,16 @@ a{color:#3370ff;text-decoration:none}
.widget-item{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius-sm);cursor:pointer;transition:all .15s;margin-bottom:2px;font-size:13px;color:var(--text)} .widget-item{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius-sm);cursor:pointer;transition:all .15s;margin-bottom:2px;font-size:13px;color:var(--text)}
.widget-item:hover{background:var(--primary-light);color:var(--primary)} .widget-item:hover{background:var(--primary-light);color:var(--primary)}
.widget-item .icon{width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:14px} .widget-item .icon{width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:14px}
.widget-item.is-limited{position:relative;color:var(--text-caption);cursor:not-allowed}
.widget-item.is-limited:hover{background:var(--bg);color:var(--text-caption)}
.widget-item.is-limited::after{content:attr(data-limited-tip);position:absolute;left:10px;top:calc(100% + 4px);z-index:20;width:max-content;max-width:210px;padding:6px 8px;border-radius:var(--radius-sm);background:#1f2937;color:#fff;font-size:12px;line-height:1.4;box-shadow:var(--shadow);opacity:0;pointer-events:none;transform:translateY(-2px);transition:opacity .15s,transform .15s}
.widget-item.is-limited:hover::after{opacity:1;transform:translateY(0)}
.tip-dialog-mask{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:rgba(15,23,42,.35)}
.tip-dialog-mask.hidden{display:none}
.tip-dialog{width:320px;max-width:calc(100vw - 32px);padding:20px;border-radius:var(--radius);background:var(--surface);box-shadow:var(--shadow-lg)}
.tip-dialog-title{font-size:16px;font-weight:600;color:var(--text);margin-bottom:12px}
.tip-dialog-content{font-size:14px;line-height:1.6;color:var(--text-secondary);margin-bottom:20px}
.tip-dialog-actions{display:flex;justify-content:flex-end}
.form-design-right{width:360px;background:var(--surface);border-left:1px solid var(--border);overflow-y:auto;flex-shrink:0} .form-design-right{width:360px;background:var(--surface);border-left:1px solid var(--border);overflow-y:auto;flex-shrink:0}
@@ -493,7 +503,7 @@ document.documentElement.setAttribute('data-app-mode', window.APP_MODE);
<div class="flex items-center gap-2"><span class="widget-group-count">1</span><span class="arrow">&#9662;</span></div> <div class="flex items-center gap-2"><span class="widget-group-count">1</span><span class="arrow">&#9662;</span></div>
</div> </div>
<div class="widget-group-items"> <div class="widget-group-items">
<div class="widget-item" onclick="addFormField('cloud_doc')" draggable="true" ondragstart="dragWidget(event,'cloud_doc')"> <div class="widget-item" id="widget-cloud-doc" onclick="handleCloudDocWidgetClick()" draggable="true" ondragstart="dragWidget(event,'cloud_doc')">
<span class="icon">&#128196;</span><span>云文档</span> <span class="icon">&#128196;</span><span>云文档</span>
</div> </div>
</div> </div>
@@ -703,6 +713,7 @@ let startCcRecipients = [];
let endCcRecipients = [{ id: 'end_cc_1', approverType: 'self', approverName: '', approverLevel: 1, roleName: '', userGroupName: '', formContactField: '', formDeptField: '' }]; let endCcRecipients = [{ id: 'end_cc_1', approverType: 'self', approverName: '', approverLevel: 1, roleName: '', userGroupName: '', formContactField: '', formDeptField: '' }];
let startFormPermissions = {}; let startFormPermissions = {};
let endFormPermissions = {}; let endFormPermissions = {};
const CLOUD_DOC_LIMIT_MESSAGE = '表单中只能放置一个文档类型的字段';
const submitterTypeLabels = { all: '全员', dept: '指定部门', member: '指定成员' }; const submitterTypeLabels = { all: '全员', dept: '指定部门', member: '指定成员' };
const approverTypeLabels = { superior: '上级', dept_head: '部门负责人', role: '角色', user_group: '用户组', member: '指定成员', self_select: '提交人自选', self: '提交人本人', form_contact: '表单内联系人', form_dept: '表单内部门', node_approver: '节点审批人', node_cc: '节点抄送人', multi_level_superior: '连续多级上级', multi_level_dept_head: '连续多级部门负责人' }; const approverTypeLabels = { superior: '上级', dept_head: '部门负责人', role: '角色', user_group: '用户组', member: '指定成员', self_select: '提交人自选', self: '提交人本人', form_contact: '表单内联系人', form_dept: '表单内部门', node_approver: '节点审批人', node_cc: '节点抄送人', multi_level_superior: '连续多级上级', multi_level_dept_head: '连续多级部门负责人' };
@@ -861,6 +872,71 @@ function getSelectedDetailParent() {
return null; return null;
} }
function hasFormFieldType(type) {
for (const f of formFields) {
if (f.type === type) return true;
if (f.detailChildren?.some(c => c.type === type)) return true;
}
return false;
}
function canAddFormFieldType(type, showMessage) {
if (type === 'cloud_doc' && hasFormFieldType('cloud_doc')) {
if (showMessage) showTipDialog(CLOUD_DOC_LIMIT_MESSAGE);
return false;
}
return true;
}
function showTipDialog(message) {
let mask = document.getElementById('tip-dialog-mask');
if (!mask) {
mask = document.createElement('div');
mask.id = 'tip-dialog-mask';
mask.className = 'tip-dialog-mask hidden';
mask.setAttribute('role', 'dialog');
mask.setAttribute('aria-modal', 'true');
mask.onclick = e => {
if (e.target === mask) closeTipDialog();
};
mask.innerHTML = `
<div class="tip-dialog">
<div class="tip-dialog-title">提示:</div>
<div class="tip-dialog-content" id="tip-dialog-content"></div>
<div class="tip-dialog-actions"><button type="button" class="ud-btn ud-btn--filled" onclick="closeTipDialog()">确定</button></div>
</div>`;
document.body.appendChild(mask);
}
const content = document.getElementById('tip-dialog-content');
if (content) content.textContent = message;
mask.classList.remove('hidden');
mask.querySelector('button')?.focus();
}
function closeTipDialog() {
document.getElementById('tip-dialog-mask')?.classList.add('hidden');
}
function syncCloudDocWidgetState() {
const el = document.getElementById('widget-cloud-doc');
if (!el) return;
const limited = hasFormFieldType('cloud_doc');
el.classList.toggle('is-limited', limited);
el.draggable = !limited;
if (limited) {
el.setAttribute('title', CLOUD_DOC_LIMIT_MESSAGE);
el.setAttribute('data-limited-tip', CLOUD_DOC_LIMIT_MESSAGE);
} else {
el.removeAttribute('title');
el.removeAttribute('data-limited-tip');
}
}
function handleCloudDocWidgetClick() {
if (!canAddFormFieldType('cloud_doc', true)) return;
addFormField('cloud_doc');
}
function syncHeaderName() { function syncHeaderName() {
const name = document.getElementById('approval-name')?.value || '审批配置'; const name = document.getElementById('approval-name')?.value || '审批配置';
const header = document.getElementById('header-approval-name'); const header = document.getElementById('header-approval-name');
@@ -1328,7 +1404,7 @@ function renderOtherOptionsSection(field) {
} }
let printExtra = ''; let printExtra = '';
if (field.type === 'attachment' && field.printable) { if (field.type === 'attachment' && field.printable) {
printExtra = '<div class="config-sub-hint">打印附件中的图片</div>'; printExtra = `<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${field.printAttachmentImages ? 'checked' : ''} onchange="updateField('${field.id}','printAttachmentImages',this.checked)"><span class="form-other-item-label">打印附件中的图片</span></label>`;
} }
return `<div class="base-form-item"> return `<div class="base-form-item">
<div class="base-form-item-label-parent"><label class="base-form-item-label">其他可选</label></div> <div class="base-form-item-label-parent"><label class="base-form-item-label">其他可选</label></div>
@@ -1425,6 +1501,7 @@ function createFormFieldData(type) {
if (type === 'single_choice') field.linkage = { enabled: false, targetId: '', mappings: {} }; if (type === 'single_choice') field.linkage = { enabled: false, targetId: '', mappings: {} };
if (type === 'bitable') { field.bitableTitle = ''; field.tableName = ''; field.refFields = []; } if (type === 'bitable') { field.bitableTitle = ''; field.tableName = ''; field.refFields = []; }
if (type === 'image') { field.allowImage = true; field.allowVideo = true; field.mobileOnlyCapture = false; } if (type === 'image') { field.allowImage = true; field.allowVideo = true; field.mobileOnlyCapture = false; }
if (type === 'attachment') field.printAttachmentImages = false;
if (type === 'department') { field.deptSelectionMode = 'single'; field.deptDisplayMode = 'leaf_only'; } if (type === 'department') { field.deptSelectionMode = 'single'; field.deptDisplayMode = 'leaf_only'; }
if (type === 'contact' || type === 'member') { field.contactSelectionMode = 'single'; field.allowSelectSelf = true; } if (type === 'contact' || type === 'member') { field.contactSelectionMode = 'single'; field.allowSelectSelf = true; }
if (type === 'related_approval') { field.scopeConfig = ''; field.onlyApproved = false; } if (type === 'related_approval') { field.scopeConfig = ''; field.onlyApproved = false; }
@@ -1480,6 +1557,7 @@ function normalizeFormField(field) {
if (field.allowVideo === undefined) field.allowVideo = true; if (field.allowVideo === undefined) field.allowVideo = true;
if (field.mobileOnlyCapture === undefined) field.mobileOnlyCapture = false; if (field.mobileOnlyCapture === undefined) field.mobileOnlyCapture = false;
} }
if (field.type === 'attachment' && field.printAttachmentImages === undefined) field.printAttachmentImages = false;
if (field.type === 'department') { if (field.type === 'department') {
if (!field.deptSelectionMode) field.deptSelectionMode = 'single'; if (!field.deptSelectionMode) field.deptSelectionMode = 'single';
if (!field.deptDisplayMode) field.deptDisplayMode = 'leaf_only'; if (!field.deptDisplayMode) field.deptDisplayMode = 'leaf_only';
@@ -1507,6 +1585,7 @@ function normalizeFormField(field) {
} }
function addFormField(type, index) { function addFormField(type, index) {
if (!canAddFormFieldType(type, true)) return;
if (type === 'detail') { if (type === 'detail') {
const field = createFormFieldData(type); const field = createFormFieldData(type);
if (!field) return; if (!field) return;
@@ -1538,6 +1617,7 @@ function addFormField(type, index) {
function addFormFieldToDetail(detailId, type) { function addFormFieldToDetail(detailId, type) {
if (type === 'detail' || !fieldMeta[type]) return; if (type === 'detail' || !fieldMeta[type]) return;
if (!canAddFormFieldType(type, true)) return;
const detail = formFields.find(f => f.id === detailId && f.type === 'detail'); const detail = formFields.find(f => f.id === detailId && f.type === 'detail');
if (!detail) return; if (!detail) return;
if (!detail.detailChildren) detail.detailChildren = []; if (!detail.detailChildren) detail.detailChildren = [];
@@ -1551,6 +1631,11 @@ function addFormFieldToDetail(detailId, type) {
} }
function dragWidget(ev, type) { function dragWidget(ev, type) {
if (!canAddFormFieldType(type, true)) {
ev.preventDefault();
ev.stopPropagation();
return;
}
ev.dataTransfer.setData('widgetType', type); ev.dataTransfer.setData('widgetType', type);
} }
function allowDrop(ev) { ev.preventDefault(); } function allowDrop(ev) { ev.preventDefault(); }
@@ -1626,6 +1711,7 @@ function commitFieldName(id, key) {
function renderFormFields() { function renderFormFields() {
const list = document.getElementById('field-list'); const list = document.getElementById('field-list');
if (!list) return; if (!list) return;
syncCloudDocWidgetState();
if (formFields.length === 0) { if (formFields.length === 0) {
list.innerHTML = FIELD_EMPTY_TIP_HTML; list.innerHTML = FIELD_EMPTY_TIP_HTML;
return; return;
@@ -2331,6 +2417,7 @@ function updateField(id, key, val) {
const f = findFormField(id); const f = findFormField(id);
if (!f) return; if (!f) return;
f[key] = val; f[key] = val;
if (key === 'printable' && f.type === 'attachment' && !val) f.printAttachmentImages = false;
const liveTextKeys = ['title', 'startTitle', 'endTitle', 'durationTitle', 'placeholder', 'defaultValue', 'unit', 'minValue', 'maxValue', 'formula', 'bitableTitle', 'tableName', 'scopeConfig', 'archiveLocation', 'customDateStart', 'customDateEnd']; const liveTextKeys = ['title', 'startTitle', 'endTitle', 'durationTitle', 'placeholder', 'defaultValue', 'unit', 'minValue', 'maxValue', 'formula', 'bitableTitle', 'tableName', 'scopeConfig', 'archiveLocation', 'customDateStart', 'customDateEnd'];
if (liveTextKeys.includes(key)) { if (liveTextKeys.includes(key)) {
if (['title', 'startTitle', 'endTitle', 'durationTitle'].includes(key)) { if (['title', 'startTitle', 'endTitle', 'durationTitle'].includes(key)) {
@@ -2346,7 +2433,7 @@ function updateField(id, key, val) {
autoSave(); autoSave();
return; return;
} }
const configOnlyKeys = ['allowSelectSelf', 'deptDisplayMode', 'deptSelectionMode', 'contactSelectionMode', 'onlyApproved', 'autoLocate', 'showDetailAddress', 'fillDetailAddress', 'locationDisplayMode', 'validateBranchRequired', 'phoneType', 'allowImage', 'allowVideo', 'mobileOnlyCapture', 'printable', 'required', 'allowEditDuration', 'dateFormat', 'dateRangeOption']; const configOnlyKeys = ['allowSelectSelf', 'deptDisplayMode', 'deptSelectionMode', 'contactSelectionMode', 'onlyApproved', 'autoLocate', 'showDetailAddress', 'fillDetailAddress', 'locationDisplayMode', 'validateBranchRequired', 'phoneType', 'allowImage', 'allowVideo', 'mobileOnlyCapture', 'printable', 'printAttachmentImages', 'required', 'allowEditDuration', 'dateFormat', 'dateRangeOption'];
if (configOnlyKeys.includes(key)) { if (configOnlyKeys.includes(key)) {
if (key === 'deptSelectionMode' || key === 'contactSelectionMode') renderFlowConfig(); if (key === 'deptSelectionMode' || key === 'contactSelectionMode') renderFlowConfig();
renderFormConfig(); renderFormConfig();
@@ -3276,6 +3363,21 @@ function renderEmptyHandlerPanel(node) {
</div>`; </div>`;
} }
function renderMultiPersonModeSection(node, title, oneLabel, allLabel, extraOptions) {
ensureNodeApprovers(node);
if (node.approvers.length <= 1) return '';
const extraHtml = (extraOptions || []).map(opt =>
`<option value="${opt.value}" ${node.multiApprover === opt.value ? 'selected' : ''}>${opt.label}</option>`
).join('');
return `<div class="item-wrap"><div class="sub-title bold">${title}</div>
<select class="ant-select" onchange="updateFlowNode('${node.id}','multiApprover',this.value)">
<option value="or" ${node.multiApprover === 'or' ? 'selected' : ''}>${oneLabel}</option>
<option value="and" ${node.multiApprover === 'and' ? 'selected' : ''}>${allLabel}</option>
${extraHtml}
</select>
</div>`;
}
function renderNodeCcPanel(node) { function renderNodeCcPanel(node) {
ensureNodeApprovers(node); ensureNodeApprovers(node);
if (!node.ccRecipients) node.ccRecipients = []; if (!node.ccRecipients) node.ccRecipients = [];
@@ -3833,12 +3935,7 @@ function renderApproverConfig(node) {
${renderNodePersonsPanel(node, '审批人', '+ 添加审批人')} ${renderNodePersonsPanel(node, '审批人', '+ 添加审批人')}
${renderEmptyApproverPanel(node)} ${renderEmptyApproverPanel(node)}
${renderSameAsSubmitterPanel(node)} ${renderSameAsSubmitterPanel(node)}
<div class="item-wrap"><div class="sub-title bold">多人审批方式</div> ${renderMultiPersonModeSection(node, '多人审批时采用的审批方式', '或签(一名审批人同意即可)', '会签(须所有审批人同意)')}
<select class="ant-select" onchange="updateFlowNode('${node.id}','multiApprover',this.value)">
<option value="or" ${node.multiApprover === 'or' ? 'selected' : ''}>或签(一名审批人同意即可)</option>
<option value="and" ${node.multiApprover === 'and' ? 'selected' : ''}>会签(须所有审批人同意)</option>
</select>
</div>
<div class="item-wrap" style="margin-top:16px;padding-top:16px;border-top:1px solid var(--border-light)"><div class="item-key">抄送人设置</div></div> <div class="item-wrap" style="margin-top:16px;padding-top:16px;border-top:1px solid var(--border-light)"><div class="item-key">抄送人设置</div></div>
${renderNodeCcPanel(node)} ${renderNodeCcPanel(node)}
</div>`; </div>`;
@@ -3873,6 +3970,7 @@ function renderHandlerConfig(node) {
body = `<div class="approval-editor-form"> body = `<div class="approval-editor-form">
${renderNodePersonsPanel(node, '办理人', '+ 添加办理人')} ${renderNodePersonsPanel(node, '办理人', '+ 添加办理人')}
${renderEmptyHandlerPanel(node)} ${renderEmptyHandlerPanel(node)}
${renderMultiPersonModeSection(node, '多人办理时采用的办理方式', '或签(一名办理人处理即可)', '会签(须所有办理人处理)', [{ value: 'sequential', label: '依次办理(按顺序依次提交)' }])}
<div class="more-info-wrap"><p class="more-info-key">提示:</p><div class="more-info-content"><p>办理人不涉及审批人去重设置,不同节点相同的办理人仍需要执行。</p><p>若办理人离职,会自动转交给办理人的上级代为处理。</p></div></div> <div class="more-info-wrap"><p class="more-info-key">提示:</p><div class="more-info-content"><p>办理人不涉及审批人去重设置,不同节点相同的办理人仍需要执行。</p><p>若办理人离职,会自动转交给办理人的上级代为处理。</p></div></div>
</div>`; </div>`;
} else if (selectedFlowTab === 'formPerm') { } else if (selectedFlowTab === 'formPerm') {