Files
feishu_approval_design/飞书审批配置文件_V1.0.html
2026-06-11 17:53:13 +08:00

4186 lines
227 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>飞书审批配置参考工具</title>
<style>
/* ===== Reset & Base ===== */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;color:#1f2329;background:#f5f6f7;overflow:hidden}
button,input,select,textarea{font-family:inherit;font-size:inherit;color:inherit}
button{cursor:pointer;background:none;border:none}
ul,ol{list-style:none}
a{color:#3370ff;text-decoration:none}
/* ===== Variables ===== */
:root{
--primary:#3370ff;
--primary-light:#e8f0fe;
--primary-hover:#245bdb;
--bg:#f5f6f7;
--surface:#fff;
--border:#dee0e3;
--border-light:#e5e6eb;
--text:#1f2329;
--text-secondary:#646a73;
--text-caption:#8f959e;
--danger:#f54a45;
--success:#34c759;
--warning:#ff9f00;
--radius:6px;
--radius-sm:4px;
--shadow:0 2px 8px rgba(31,35,41,0.08);
--shadow-lg:0 8px 24px rgba(31,35,41,0.12);
}
/* ===== Scrollbar ===== */
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:#c9cdd4;border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:#a5a9b0}
/* ===== Layout ===== */
.create-approval{display:flex;flex-direction:column;height:100vh}
.create-approval-header{height:56px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 16px;flex-shrink:0;gap:12px;position:relative}
.page-contact-hint{position:absolute;right:16px;top:50%;transform:translateY(-50%);font-size:12px;color:var(--text-caption);white-space:nowrap;z-index:2;pointer-events:none}
.create-approval-header-back-btn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;border-radius:6px;color:var(--text-secondary)}
.create-approval-header-back-btn:hover{background:var(--bg)}
.create-approval-header-name{font-size:16px;font-weight:600;color:var(--text);white-space:nowrap}
.create-approval-header-tab-list{display:flex;align-items:center;gap:4px;margin:0 auto}
.create-approval-header-tab-item{display:flex;align-items:center;gap:6px;padding:6px 16px;border-radius:6px;color:var(--text-secondary);font-size:14px;cursor:pointer;transition:all .2s}
.create-approval-header-tab-item:hover{background:var(--bg)}
.create-approval-header-tab-item-active{background:var(--primary-light);color:var(--primary);font-weight:500}
.create-approval-header-tab-counter{width:20px;height:20px;border-radius:50%;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:12px}
.create-approval-header-tab-item-active .create-approval-header-tab-counter{background:var(--primary);color:#fff}
.create-approval-header-right{display:flex;align-items:center;gap:12px;margin-left:auto;flex-shrink:0;z-index:2}
.create-approval-header-right .page-contact-hint{position:static;transform:none;pointer-events:auto}
/* Buttons */
.ud-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:7px 16px;border-radius:var(--radius-sm);font-size:14px;transition:all .15s;border:1px solid transparent;white-space:nowrap}
.ud-btn--outlined{background:var(--surface);border-color:var(--border);color:var(--text)}
.ud-btn--outlined:hover{background:var(--bg)}
.ud-btn--filled{background:var(--primary);color:#fff;border-color:var(--primary)}
.ud-btn--filled:hover{background:var(--primary-hover)}
.ud-btn--text{background:transparent;color:var(--primary)}
.ud-btn--text:hover{background:var(--primary-light)}
.ud-btn--link{background:transparent;color:var(--text-secondary)}
.ud-btn--link:hover{background:var(--bg)}
.ud-btn--icon{width:32px;height:32px;padding:0}
.ud-btn--sm{padding:5px 12px;font-size:13px}
/* ===== Main Content ===== */
.create-approval-body{flex:1;display:flex;overflow:hidden}
/* ===== FORM DESIGN ===== */
.form-design{display:flex;width:100%;height:100%;min-height:0}
.form-design-left{width:240px;background:var(--surface);border-right:1px solid var(--border);overflow-y:auto;flex-shrink:0}
.form-design-center{flex:1;display:flex;justify-content:center;align-items:stretch;padding:16px 24px;overflow:hidden;background:var(--bg);min-height:0}
.canvas-wrap{width:375px;height:100%;max-height:100%;display:flex;flex-direction:column;min-height:0}
/* Mobile Preview */
.mobile-preview{width:100%;height:100%;max-height:100%;background:var(--surface);border-radius:12px;box-shadow:var(--shadow);padding:0;position:relative;overflow:hidden;display:flex;flex-direction:column;min-height:420px}
.mobile-preview-header{height:44px;flex-shrink:0;background:var(--surface);border-bottom:1px solid var(--border-light);display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:600;position:relative;padding:0 88px 0 16px}
.mobile-preview-header-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mobile-preview-clear{position:absolute;right:8px;top:50%;transform:translateY(-50%);padding:4px 8px;font-size:12px;color:var(--text-secondary);background:transparent;border:none;border-radius:var(--radius-sm);cursor:pointer;white-space:nowrap}
.mobile-preview-clear:hover{color:var(--primary);background:var(--primary-light)}
.mobile-preview-body{flex:1;overflow-y:auto;overflow-x:hidden;min-height:0;padding:0}
.field-list{min-height:120px}
.widget-list-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.5px}
.widget-group{margin-bottom:4px}
.widget-group-title{padding:10px 16px;font-weight:600;font-size:13px;color:var(--text);cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none}
.widget-group-title:hover{background:var(--bg)}
.widget-group-title .arrow{transition:transform .2s;font-size:12px;color:var(--text-caption)}
.widget-group.collapsed .arrow{transform:rotate(-90deg)}
.widget-group.collapsed .widget-group-items{display:none}
.widget-group-count{font-size:11px;color:var(--text-caption);background:var(--bg);padding:1px 6px;border-radius:10px}
.widget-group-items{padding:0 12px 8px}
.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 .icon{width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:14px}
.form-design-right{width:360px;background:var(--surface);border-left:1px solid var(--border);overflow-y:auto;flex-shrink:0}
/* Widget List */
.field-list-tip{display:flex;align-items:center;justify-content:center;gap:8px;padding:40px 20px;color:var(--text-caption);font-size:13px}
.field-item{border-bottom:1px solid var(--border-light);cursor:pointer;transition:background .15s;position:relative}
.field-item:hover{background:#fafafa}
.field-item.active{background:var(--primary-light)}
.field-item-inner-wrapper{padding:14px 16px}
.base-view{display:flex;flex-direction:column;gap:4px}
.base-view-title{display:flex;flex-direction:column;gap:2px}
.base-view-name{font-size:14px;color:var(--text);font-weight:500;display:flex;align-items:center;gap:4px}
.base-view-required::after{content:"*";color:var(--danger);margin-left:2px}
.base-view-placeholder{font-size:13px;color:var(--text-caption)}
.base-view-right-arrow{position:absolute;right:12px;top:50%;transform:translateY(-50%);color:var(--text-caption);font-size:12px}
.base-view-has-right-arrow{padding-right:28px}
.widget-view-detail{border:1px solid var(--border-light);border-radius:var(--radius);overflow:hidden;margin:4px 0}
.widget-view-detail-header{padding:10px 12px;background:var(--bg);font-size:13px;font-weight:600;color:var(--text-secondary);border-bottom:1px solid var(--border-light)}
.widget-view-detail-body{padding:8px;min-height:48px}
.widget-view-detail-body.detail-drop-over{background:var(--primary-light)}
.widget-view-detail-body .field-item{border-bottom:none;border:1px solid var(--border-light);border-radius:var(--radius-sm);margin-bottom:6px;position:relative}
.widget-view-detail-empty{margin:4px 0;padding:14px 12px;text-align:center;color:var(--text-caption);font-size:12px;border:1px dashed var(--border);border-radius:var(--radius-sm)}
.date-range-view{display:flex;flex-direction:column}
.date-range-view .base-view{padding:10px 12px;border-bottom:1px solid var(--border-light);position:relative}
.date-range-view .base-view:last-child{border-bottom:none}
.date-interval-unit{font-size:12px;color:var(--text-caption);margin-top:4px}
.widget-view-detail-footer{padding:10px 12px;display:flex;align-items:center;justify-content:center;gap:6px;color:var(--primary);font-size:13px;cursor:pointer;border-top:1px solid var(--border-light)}
.widget-view-detail-footer:hover{background:var(--primary-light)}
.base-applicant-view{display:flex;flex-direction:column;gap:8px}
.base-applicant-view-row{display:flex;align-items:center;gap:8px}
.base-applicant-view-name{font-size:14px;color:var(--text);font-weight:500}
.base-applicant-view-placeholder{font-size:13px;color:var(--text-caption)}
/* Field Actions */
.field-actions{position:absolute;right:8px;top:8px;display:none;gap:2px}
.field-item:hover .field-actions{display:flex}
.field-action{width:24px;height:24px;display:flex;align-items:center;justify-content:center;border-radius:4px;color:var(--text-caption);font-size:12px;cursor:pointer}
.field-action:hover{background:var(--bg);color:var(--text)}
.field-action.delete:hover{color:var(--danger);background:#fff0f0}
/* Inline field name editing on canvas */
.field-name-input{border:none;background:transparent;font-size:14px;font-weight:500;color:var(--text);width:100%;padding:2px 4px;margin:-2px -4px;font-family:inherit;border-radius:3px;box-sizing:content-box;max-width:calc(100% - 8px)}
.field-name-input:hover{background:rgba(51,112,255,.06)}
.field-name-input:focus{outline:none;background:var(--surface);box-shadow:0 0 0 2px var(--primary-light)}
.widget-view-detail-header .field-name-input{font-size:13px;font-weight:600;color:var(--text-secondary)}
.date-range-view .field-name-input{font-size:14px}
/* Config Panel (Right) */
.drawer{width:360px;height:100%}
.config-panel{padding:0}
.config-panel-title{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.config-panel-title-name{font-size:15px;font-weight:600}
.config-panel-tabs{display:flex;border-bottom:1px solid var(--border)}
.config-panel-tab-item{flex:1;padding:12px;text-align:center;font-size:13px;font-weight:500;cursor:pointer;color:var(--text-secondary);border-bottom:2px solid transparent;margin-bottom:-1px}
.config-panel-tab-item-active{color:var(--primary);border-bottom-color:var(--primary)}
/* Form Items */
.base-form-item{padding:12px 20px}
.base-form-item-label-parent{margin-bottom:6px}
.base-form-item-label{font-size:13px;color:var(--text);font-weight:500}
.base-form-item-label-required::after{content:" *";color:var(--danger)}
.base-form-item-control-wrapper{}
.ant-input{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;transition:all .2s;background:var(--surface)}
.ant-input:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px var(--primary-light)}
.ant-select{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;background:var(--surface);cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'%3E%3Cpath d='M2.293 7.293a1 1 0 0 1 1.414 0L12 15.586l8.293-8.293a1 1 0 1 1 1.414 1.414l-9 9a1 1 0 0 1-1.414 0l-9-9a1 1 0 0 1 0-1.414z' fill='%23646a73'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px}
.ant-select:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px var(--primary-light)}
.ud-switch{position:relative;width:36px;height:20px;display:inline-block}
.ud-switch input{opacity:0;width:0;height:0}
.ud-switch .slider{position:absolute;cursor:pointer;inset:0;background:#c9cdd4;border-radius:20px;transition:.2s}
.ud-switch .slider::before{content:"";position:absolute;height:16px;width:16px;left:2px;bottom:2px;background:#fff;border-radius:50%;transition:.2s}
.ud-switch input:checked+.slider{background:var(--primary)}
.ud-switch input:checked+.slider::before{transform:translateX(16px)}
.form-other-item{display:flex;align-items:center;gap:8px;margin-bottom:8px;cursor:pointer;font-size:13px}
.form-other-item-checkbox{width:16px;height:16px;accent-color:var(--primary);cursor:pointer}
.form-other-item-label{color:var(--text)}
/* Visibility Settings */
.visibility-empty{color:var(--text-caption);font-size:13px;text-align:center;padding:24px}
.condition-group{background:var(--bg);border-radius:var(--radius-sm);padding:12px;margin:0 20px 10px}
.condition-group-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;font-size:12px;font-weight:600;color:var(--text-secondary)}
.condition-row{display:flex;gap:6px;margin-bottom:8px;align-items:flex-start;flex-wrap:wrap}
.condition-row select,.condition-row input{flex:1;min-width:80px;padding:6px 8px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:12px}
.condition-value-wrap{flex:1;min-width:120px}
.condition-choice-values{display:flex;flex-direction:column;gap:4px;max-height:120px;overflow-y:auto;padding:4px 0}
.condition-choice-item{display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer}
.condition-remove{width:22px;height:22px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--text-caption);border:none;background:none;font-size:14px}
.condition-remove:hover{color:var(--danger)}
.btn-add-condition{display:inline-flex;align-items:center;gap:4px;color:var(--primary);font-size:12px;cursor:pointer;border:none;background:none;margin-top:4px}
.btn-add-condition:hover{color:var(--primary-hover)}
.or-divider{text-align:center;font-size:12px;color:var(--text-caption);margin:10px 20px;position:relative}
.or-divider::before,.or-divider::after{content:"";position:absolute;top:50%;width:40%;height:1px;background:var(--border)}
.or-divider::before{left:0}.or-divider::after{right:0}
/* ===== FLOW DESIGN ===== */
.flow-design{display:flex;width:100%;height:100%}
.flow-sidebar{width:200px;background:var(--surface);border-right:1px solid var(--border);overflow-y:auto;flex-shrink:0}
.flow-canvas-wrap{flex:1;overflow:auto;background:var(--bg);display:flex;justify-content:center;padding:40px;position:relative}
.flow-canvas{display:flex;flex-direction:column;align-items:center;min-width:600px}
.flow-editor{width:100%;display:flex;flex-direction:column;align-items:center}
/* Flow Nodes */
.flow-editor-node{display:flex;flex-direction:column;align-items:center;width:240px;margin-bottom:0;position:relative}
.flow-editor-node-container{width:100%;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;cursor:pointer;transition:all .15s;position:relative}
.flow-editor-node-container:hover{border-color:var(--primary)}
.flow-editor-node.active .flow-editor-node-container{border-color:var(--primary);box-shadow:0 0 0 3px var(--primary-light)}
.node-title{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.node-title-name{font-weight:600;font-size:14px}
.node-content{display:flex;align-items:center;justify-content:space-between}
.node-detail{font-size:12px;color:var(--text-secondary);display:flex;flex-direction:column;gap:4px}
.node-detail-item{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px}
.node-edit-btn{color:var(--text-caption);cursor:pointer;font-size:14px;margin-left:auto}
.node-edit-btn:hover{color:var(--primary)}
.delete-btn{position:absolute;right:-10px;top:-10px;width:20px;height:20px;display:flex;align-items:center;justify-content:center;background:var(--surface);border:1px solid var(--border);border-radius:50%;color:var(--text-caption);cursor:pointer;font-size:10px;opacity:0;transition:opacity .15s}
.flow-editor-node-container:hover .delete-btn{opacity:1}
.delete-btn:hover{color:var(--danger);border-color:var(--danger)}
/* Start/End Nodes */
.flow-editor-node.start .flow-editor-node-container,.flow-editor-node.end .flow-editor-node-container{background:var(--bg);border-style:dashed;text-align:center}
.flow-editor-node.start .node-title,.flow-editor-node.end .node-title{justify-content:center;margin-bottom:0}
.flow-editor-node.start .node-title-name,.flow-editor-node.end .node-title-name{color:var(--text-secondary)}
/* Node Type Colors */
.flow-editor-node.approval .node-title-name{color:var(--primary)}
.flow-editor-node.cc .node-title-name{color:var(--success)}
.flow-editor-node.handler .node-title-name{color:var(--warning)}
/* Connections */
.bottom-v-container{display:flex;flex-direction:column;align-items:center;height:48px;position:relative;width:100%}
.flow-v-track{width:2px;height:32px;background:#646a73;border-radius:1px}
.flow-arrow-down{display:block;color:#646a73;margin-top:-1px;flex-shrink:0}
.add-node-btn{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:10}
.add-btn{width:24px;height:24px;background:var(--primary);color:#fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;cursor:pointer;border:2px solid var(--surface);transition:all .15s;box-shadow:0 1px 4px rgba(31,35,41,.12)}
.add-btn:hover{transform:scale(1.08)}
/* Branch Nodes per-branch corner rails (no overflow) */
.flow-route-wrap{width:100%;display:flex;flex-direction:column;align-items:center}
.flow-split-from-add{width:100%;position:relative;height:14px;flex-shrink:0;display:flex;justify-content:center}
.flow-split-from-add::before{content:"";position:absolute;top:0;left:50%;width:2px;height:14px;background:#646a73;transform:translateX(-50%)}
.flow-split-from-add .flow-arrow-down{position:relative;z-index:1;margin-top:0}
.flow-branch-label{cursor:pointer;transition:all .15s}
.flow-branch-label:hover,.flow-branch-label.active{border-color:var(--primary);color:var(--primary);background:#e8f0fe}
.flow-route-wrap > .flow-editor-node{display:none}
.flow-split-zone{width:100%;position:relative;height:12px;flex-shrink:0}
.flow-split-zone::before{content:"";position:absolute;top:0;left:50%;width:2px;height:12px;background:#646a73;transform:translateX(-50%)}
.flow-merge-zone{width:100%;position:relative;height:26px;flex-shrink:0;margin-top:0}
.flow-merge-zone::after{content:"";position:absolute;top:0;left:50%;width:2px;height:14px;background:#646a73;transform:translateX(-50%)}
.flow-merge-zone .flow-arrow-down{position:absolute;bottom:0;left:50%;transform:translateX(-50%);color:#646a73}
.flow-branch-container{display:flex;gap:0;align-items:stretch;justify-content:center;width:100%;position:relative;padding:0 8px;--branch-count:2}
.flow-branch{display:flex;flex-direction:column;align-items:center;flex:1;min-width:0;max-width:none;position:relative;padding:0 10px}
.flow-branch-rail-top,.flow-branch-rail-bottom{width:100%;height:2px;position:relative;flex-shrink:0}
.flow-branch-rail-top::before,.flow-branch-rail-bottom::before{content:"";position:absolute;height:2px;background:#646a73;top:0}
.flow-branch-rail-top::before{left:50%;right:0}
.flow-branch-rail-bottom::before{left:50%;right:0;bottom:0;top:auto}
.flow-branch:last-child:not(:first-child) .flow-branch-rail-top::before,.flow-branch:last-child:not(:first-child) .flow-branch-rail-bottom::before{left:0;right:50%}
.flow-branch:not(:first-child):not(:last-child) .flow-branch-rail-top::before,.flow-branch:not(:first-child):not(:last-child) .flow-branch-rail-bottom::before{left:0;right:0}
.flow-branch:first-child:last-child .flow-branch-rail-top,.flow-branch:first-child:last-child .flow-branch-rail-bottom{display:none}
.flow-branch-stem{width:2px;height:12px;background:#646a73;position:relative;flex-shrink:0;margin-top:0}
.flow-branch-stem .flow-arrow-down{position:absolute;bottom:-11px;left:50%;transform:translateX(-50%);z-index:1}
.flow-branch-label,.flow-branch-else{font-size:12px;color:var(--text-secondary);background:var(--bg);padding:4px 12px;border-radius:12px;margin:12px 0 8px;white-space:nowrap;border:1px solid var(--border-light)}
.flow-branch-else{cursor:default}
.flow-branch-else:hover{border-color:var(--border-light);color:var(--text-caption);background:var(--bg)}
.flow-branch-else.active{border-color:var(--primary);color:var(--primary)}
.flow-branch-content{width:100%;display:flex;flex-direction:column;align-items:center;flex:1}
.flow-branch-tail{width:2px;flex:1;min-height:16px;background:#646a73;margin-top:8px;position:relative}
.flow-branch-content .bottom-v-container{height:44px}
.flow-branch-content .flow-v-track{height:28px}
/* Add Menu */
.add-menu{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);background:var(--surface);border-radius:var(--radius);box-shadow:var(--shadow-lg);border:1px solid var(--border);padding:6px;display:none;flex-direction:column;gap:2px;min-width:140px;z-index:100}
.add-menu.show{display:flex}
.add-menu-item{padding:8px 12px;border-radius:var(--radius-sm);cursor:pointer;font-size:13px;display:flex;align-items:center;gap:8px;white-space:nowrap}
.add-menu-item:hover{background:var(--bg)}
/* Flow Right Panel */
.approval-editor-drawer{width:360px;height:100%;background:var(--surface);border-left:1px solid var(--border);overflow-y:auto}
.approval-editor-name-wrapper{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px}
.approval-editor-name{font-size:15px;font-weight:600;flex:1}
.approval-editor-tab-wrapper{padding:12px 20px;border-bottom:1px solid var(--border)}
.approval-editor-form{padding:12px 0}
.item-wrap{padding:8px 20px}
.item-key{font-size:13px;font-weight:500;color:var(--text);margin-bottom:8px}
.approver-item-wrap-margin{margin-top:8px}
.approver-list{display:flex;flex-direction:column;gap:8px}
.selected-content{display:flex;align-items:center;gap:8px;margin-top:8px}
.add-button{color:var(--primary);font-size:13px}
.more-info,.more-info-wrap{padding:8px 0;font-size:12px;color:var(--text-caption)}
.more-info-key{font-size:13px;font-weight:500;color:var(--text);margin-bottom:4px}
.btn-group{display:flex;gap:8px;justify-content:flex-end;padding:16px 20px;border-top:1px solid var(--border);margin-top:12px}
/* Radio Group */
.ant-radio-group{display:flex;background:var(--bg);border-radius:var(--radius-sm);padding:3px;gap:3px}
.ant-radio-button-wrapper{flex:1;padding:7px 12px;text-align:center;font-size:13px;color:var(--text-secondary);cursor:pointer;border-radius:4px;transition:all .2s;background:transparent;border:none}
.ant-radio-button-wrapper-checked{background:var(--surface);color:var(--primary);box-shadow:0 1px 3px rgba(31,35,41,0.1);font-weight:500}
/* Condition Editor */
.condition-editor-drawer{width:360px;height:100%;background:var(--surface);border-left:1px solid var(--border);overflow-y:auto}
.condition-header-wrap{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.condition-name-box{display:flex;align-items:center;gap:8px}
.condition-name{font-size:15px;font-weight:600}
.edit-icon{color:var(--text-caption);cursor:pointer;font-size:14px}
.edit-icon:hover{color:var(--primary)}
.info-icon{color:var(--text-caption);font-size:16px}
.top-tips{display:flex;justify-content:space-between;align-items:center;padding:12px 20px;font-size:13px;color:var(--text-secondary)}
.top-tips .link{color:var(--primary);font-size:13px}
.operate{padding:0 20px 12px}
.w-full{width:100%}
/* Zoom Controls */
.zoom-controls{position:fixed;bottom:24px;right:24px;display:flex;align-items:center;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:4px;box-shadow:var(--shadow)}
/* Branch add button */
.flow-editor-node.route{width:100%;max-width:none;align-items:center}
.flow-editor-node.route > .flow-editor-node{width:240px}
.top-h-line{display:none}
.add-branch{display:flex;justify-content:center;padding:4px 0 12px;cursor:pointer}
.add-branch-inner{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--primary);padding:5px 14px;border:1px dashed var(--primary);border-radius:16px;background:var(--surface);transition:background .15s}
.add-branch-inner:hover{background:#e8f0fe}
.flow-route-wrap.parallel-route .add-branch-inner{color:#0ea5e9;border-color:#0ea5e9}
.flow-route-wrap.parallel-route .add-branch-inner:hover{background:#e0f2fe}
.flow-route-wrap.active > .flow-editor-node .flow-editor-node-container{border-color:var(--primary);box-shadow:0 0 0 3px var(--primary-light)}
.approver-wrapper{border:1px solid var(--border);border-radius:8px;margin-bottom:12px;overflow:hidden}
.approver-wrapper .header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;background:var(--bg);font-size:13px;font-weight:500}
.approver-wrapper .delete-cc{color:var(--text-caption);cursor:pointer;font-size:18px;line-height:1;border:none;background:none;padding:0 4px}
.approver-wrapper .delete-cc:hover{color:var(--danger)}
.approver-wrapper .main-content{padding:12px 14px}
/* ===== Utility ===== */
.hidden{display:none !important}
.text-muted{color:var(--text-caption)}
.flex{display:flex}
.items-center{align-items:center}
.gap-2{gap:8px}
.gap-4{gap:16px}
.w-full{width:100%}
.mb-2{margin-bottom:8px}
.mb-4{margin-bottom:16px}
.mt-2{margin-top:8px}
.p-4{padding:16px}
/* Sub panel in flow editor */
.sub-panel{padding:8px 0}
.sub-panel-seperate-line{height:1px;background:var(--border);margin:8px 0}
.sub-title{font-size:13px;color:var(--text);margin-bottom:8px}
.sub-title.bold{font-weight:600}
.level-select-wrapper{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.level-select-title{font-size:13px;color:var(--text)}
.supervisor-select-tip{font-size:12px;color:var(--text-caption);padding:8px 0}
.approver-select{width:100%}
.approver-tip-question-icon{color:var(--text-caption);margin-left:4px;font-size:14px}
/* Tag / Avatar */
.ud-tag{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg);border-radius:4px;font-size:12px}
/* Tooltip helper */
.radio-wrapper{padding:4px 0}
.ud-radio-wrapper{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px}
.ud-radio{width:16px;height:16px;border:1px solid var(--border);border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.ud-radio__input{display:none}
.ud-radio__wallpaper{width:8px;height:8px;border-radius:50%;background:transparent}
.ud-radio__input:checked+.ud-radio__wallpaper{background:var(--primary)}
.ud-radio-wrapper--checked .ud-radio{border-color:var(--primary)}
.ud-radio-wrapper.is-disabled{opacity:.45;cursor:not-allowed;pointer-events:none}
/* Error tip */
.error-tip{color:var(--danger);font-size:12px;margin-bottom:8px}
/* ===== File Input hidden ===== */
/* Form permission table */
.form-perm-table{width:100%;border-collapse:collapse;font-size:12px;margin-top:8px}
.form-perm-table th,.form-perm-table td{border:1px solid var(--border);padding:8px 10px;text-align:left}
.form-perm-table th{background:var(--bg);color:var(--text-secondary);font-weight:600}
.form-perm-radio{display:flex;gap:12px;flex-wrap:wrap}
.form-perm-radio label{display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px}
.node-name-input{font-size:15px;font-weight:600;border:none;background:transparent;width:100%;outline:none;color:var(--text)}
.node-name-input:focus{background:var(--bg);border-radius:4px;padding:2px 6px}
.config-panel-empty{min-height:200px}
.config-panel-hint{padding:20px;color:var(--text-secondary);font-size:13px;line-height:1.6}
.node-title-name-wrap{display:flex;align-items:center;gap:4px;min-width:0;flex:1}
.flow-node-name-input{border:none;background:transparent;font-size:14px;font-weight:600;color:inherit;width:100%;min-width:0;padding:2px 4px;margin:-2px -4px;border-radius:3px;font-family:inherit}
.flow-editor-node.approval .flow-node-name-input{color:var(--primary)}
.flow-editor-node.cc .flow-node-name-input{color:var(--success)}
.flow-editor-node.handler .flow-node-name-input{color:var(--warning)}
.flow-node-name-input:hover{background:rgba(51,112,255,.06)}
.flow-node-name-input:focus{outline:none;background:var(--surface);box-shadow:0 0 0 2px var(--primary-light)}
.node-name-edit-hint{flex-shrink:0;color:var(--text-caption);display:flex;align-items:center;cursor:text;opacity:.7}
.node-name-edit-hint svg{width:14px;height:14px}
.form-perm-table .form-perm-detail-header td{background:var(--bg);font-weight:600;font-size:13px;color:var(--text-secondary);padding:10px 12px}
.form-perm-table .form-perm-detail-child td:first-child{padding-left:24px;color:var(--text-secondary)}
.config-field-hint{color:var(--text-caption);font-size:12px;line-height:1.5;margin-bottom:10px;padding:8px 10px;background:var(--bg);border-radius:4px}
.config-radio-group{display:flex;flex-direction:column;gap:8px}
.config-radio-item{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px}
.config-inline-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:13px}
.amount-format-preview{margin-top:10px;padding:10px;background:var(--bg);border-radius:4px;font-size:13px;color:var(--text-secondary)}
.linkage-rule-block{margin-top:10px;padding:10px;border:1px solid var(--border-light);border-radius:4px}
.linkage-rule-title{font-size:12px;color:var(--text-secondary);margin-bottom:6px}
.config-toggle-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 0;font-size:13px}
.config-sub-hint{font-size:12px;color:var(--text-caption);margin-top:4px}
.ref-field-row{display:flex;gap:6px;align-items:center;margin-bottom:6px}
.currency-select-wrap{position:relative}
.currency-select-trigger{width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;text-align:left;cursor:pointer;background:var(--surface)}
.currency-select-trigger-text{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.currency-select-arrow{color:var(--text-caption);flex-shrink:0}
.currency-select-panel{position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:20;background:var(--surface);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,.1)}
.currency-select-search{padding:8px;border-bottom:1px solid var(--border-light)}
.currency-select-list{max-height:220px;overflow-y:auto;padding:4px 0}
.currency-select-item{display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;font-size:13px}
.currency-select-item:hover{background:var(--bg)}
.currency-select-item.hidden{display:none}
.currency-select-footer{padding:8px 12px;border-top:1px solid var(--border-light)}
.custom-date-range-config{padding:10px;background:var(--bg);border-radius:4px}
.data-load-banner{background:#fff7e6;border-bottom:1px solid #ffd591;color:#ad6800;padding:10px 16px;font-size:13px;line-height:1.5;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-shrink:0}
.data-load-banner.hidden{display:none !important}
html[data-app-mode="standalone"] .platform-only{display:none !important}
</style>
<script id="app-boot">
window.APP_PROJECT = { id: 'approval_of_design', label: '飞书审批配置服务' };
window.APP_MODE = 'server';
window.__INITIAL_CONFIG__ = null;
document.documentElement.setAttribute('data-app-mode', window.APP_MODE);
</script>
</head>
<body>
<div class="create-approval">
<!-- Header -->
<div class="create-approval-header">
<button class="create-approval-header-back-btn ud-btn ud-btn--link" title="返回">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M16.293 2.293a1 1 0 0 1 0 1.414L8 12l8.293 8.293a1 1 0 0 1-1.414 1.414l-8.293-8.293a2 2 0 0 1 0-2.828l8.293-8.293a1 1 0 0 1 1.414 0Z" fill="currentColor"/></svg>
</button>
<div class="create-approval-header-name" id="header-approval-name">新建审批</div>
<button class="ud-btn ud-btn--text ud-btn--sm" style="color:var(--text-caption)">
<span>草稿(设计中)</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style="margin-left:4px"><path d="M2.293 7.707a1 1 0 0 1 1.414 0L12 16l8.293-8.293a1 1 0 1 1 1.414 1.414l-8.293 8.293a2 2 0 0 1-2.828 0L2.293 9.121a1 1 0 0 1 0-1.414Z" fill="currentColor"/></svg>
</button>
<div class="create-approval-header-tab-list">
<div class="create-approval-header-tab-item" data-tab="basic" onclick="switchMainTab('basic')">
<span class="create-approval-header-tab-counter">1</span><span>基础信息</span>
</div>
<div class="create-approval-header-tab-item create-approval-header-tab-item-active" data-tab="form" onclick="switchMainTab('form')">
<span class="create-approval-header-tab-counter">2</span><span>表单设计</span>
</div>
<div class="create-approval-header-tab-item" data-tab="flow" onclick="switchMainTab('flow')">
<span class="create-approval-header-tab-counter">3</span><span>流程设计</span>
</div>
</div>
<div class="create-approval-header-right">
<input type="file" id="import-html-input" class="platform-only" accept=".html,text/html" hidden onchange="importHtmlFile(this)">
<button type="button" class="ud-btn ud-btn--text ud-btn--sm platform-only" id="btn-import-html" onclick="document.getElementById('import-html-input').click()">导入</button>
<button type="button" class="ud-btn ud-btn--filled ud-btn--sm platform-only" id="btn-export-html" onclick="exportConfiguredHtml()">导出 审批流设计文档</button>
<div class="page-contact-hint">如您有疑问可联系TH818部门人员</div>
</div>
</div>
<!-- Body -->
<div class="create-approval-body">
<!-- Basic Info Tab -->
<div id="tab-basic" class="hidden" style="width:100%;padding:40px;display:flex;justify-content:center">
<div style="width:600px;background:var(--surface);border-radius:var(--radius);padding:32px;box-shadow:var(--shadow)">
<div class="base-form-item">
<div class="base-form-item-label-parent"><label class="base-form-item-label base-form-item-label-required">审批名称</label></div>
<div class="base-form-item-control-wrapper"><input type="text" class="ant-input" id="approval-name" value="新建审批" oninput="syncHeaderName()"></div>
</div>
<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-control-wrapper"><textarea class="ant-input" id="approval-desc" rows="3" placeholder="填写审批说明,帮助员工了解审批用途" oninput="autoSave()"></textarea></div>
</div>
<div class="base-form-item">
<div class="base-form-item-label-parent"><label class="base-form-item-label base-form-item-label-required">谁可以提交该审批</label></div>
<div class="base-form-item-control-wrapper">
<select class="ant-select" id="submitter-type" onchange="updateSubmitterType(this.value)">
<option value="all" selected>全员</option>
<option value="dept">指定部门</option>
<option value="member">指定成员</option>
</select>
</div>
</div>
<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-control-wrapper"><input type="text" class="ant-input" id="submitter-value" value="" placeholder="例如TH8数字化与数据中心" oninput="updateSubmitterValueLive(this.value)"></div>
</div>
</div>
</div>
<!-- Form Design Tab -->
<div id="tab-form" class="form-design">
<!-- Left: Widget Library -->
<div class="form-design-left">
<div class="widget-list-header">组件库</div>
<div class="widget-group" data-group="doc">
<div class="widget-group-title" onclick="toggleWidgetGroup(this)">
<span>飞书云文档</span>
<div class="flex items-center gap-2"><span class="widget-group-count">1</span><span class="arrow">&#9662;</span></div>
</div>
<div class="widget-group-items">
<div class="widget-item" onclick="addFormField('cloud_doc')" draggable="true" ondragstart="dragWidget(event,'cloud_doc')">
<span class="icon">&#128196;</span><span>云文档</span>
</div>
</div>
</div>
<div class="widget-group" data-group="text">
<div class="widget-group-title" onclick="toggleWidgetGroup(this)">
<span>文本</span>
<div class="flex items-center gap-2"><span class="widget-group-count">3</span><span class="arrow">&#9662;</span></div>
</div>
<div class="widget-group-items">
<div class="widget-item" onclick="addFormField('single_text')" draggable="true" ondragstart="dragWidget(event,'single_text')"><span class="icon">&#128221;</span><span>单行文本</span></div>
<div class="widget-item" onclick="addFormField('multi_text')" draggable="true" ondragstart="dragWidget(event,'multi_text')"><span class="icon">&#128220;</span><span>多行文本</span></div>
<div class="widget-item" onclick="addFormField('desc_text')" draggable="true" ondragstart="dragWidget(event,'desc_text')"><span class="icon">&#128172;</span><span>说明</span></div>
</div>
</div>
<div class="widget-group" data-group="number">
<div class="widget-group-title" onclick="toggleWidgetGroup(this)">
<span>数值</span>
<div class="flex items-center gap-2"><span class="widget-group-count">3</span><span class="arrow">&#9662;</span></div>
</div>
<div class="widget-group-items">
<div class="widget-item" onclick="addFormField('number')" draggable="true" ondragstart="dragWidget(event,'number')"><span class="icon">&#128290;</span><span>数字</span></div>
<div class="widget-item" onclick="addFormField('amount')" draggable="true" ondragstart="dragWidget(event,'amount')"><span class="icon">&#128176;</span><span>金额</span></div>
<div class="widget-item" onclick="addFormField('formula')" draggable="true" ondragstart="dragWidget(event,'formula')"><span class="icon">&#128425;</span><span>计算公式</span></div>
</div>
</div>
<div class="widget-group" data-group="option">
<div class="widget-group-title" onclick="toggleWidgetGroup(this)">
<span>选项</span>
<div class="flex items-center gap-2"><span class="widget-group-count">2</span><span class="arrow">&#9662;</span></div>
</div>
<div class="widget-group-items">
<div class="widget-item" onclick="addFormField('single_choice')" draggable="true" ondragstart="dragWidget(event,'single_choice')"><span class="icon">&#9711;</span><span>单选</span></div>
<div class="widget-item" onclick="addFormField('multi_choice')" draggable="true" ondragstart="dragWidget(event,'multi_choice')"><span class="icon">&#9745;</span><span>多选</span></div>
</div>
</div>
<div class="widget-group" data-group="date">
<div class="widget-group-title" onclick="toggleWidgetGroup(this)">
<span>日期</span>
<div class="flex items-center gap-2"><span class="widget-group-count">2</span><span class="arrow">&#9662;</span></div>
</div>
<div class="widget-group-items">
<div class="widget-item" onclick="addFormField('date')" draggable="true" ondragstart="dragWidget(event,'date')"><span class="icon">&#128197;</span><span>日期</span></div>
<div class="widget-item" onclick="addFormField('date_range')" draggable="true" ondragstart="dragWidget(event,'date_range')"><span class="icon">&#128198;</span><span>日期区间</span></div>
</div>
</div>
<div class="widget-group" data-group="other">
<div class="widget-group-title" onclick="toggleWidgetGroup(this)">
<span>其他</span>
<div class="flex items-center gap-2"><span class="widget-group-count">12</span><span class="arrow">&#9662;</span></div>
</div>
<div class="widget-group-items">
<div class="widget-item" onclick="addFormField('detail')" draggable="true" ondragstart="dragWidget(event,'detail')"><span class="icon">&#128463;</span><span>明细/表格</span></div>
<div class="widget-item" onclick="addFormField('bitable')" draggable="true" ondragstart="dragWidget(event,'bitable')"><span class="icon">&#128202;</span><span>引用多维表格</span></div>
<div class="widget-item" onclick="addFormField('image')" draggable="true" ondragstart="dragWidget(event,'image')"><span class="icon">&#128247;</span><span>图片/视频</span></div>
<div class="widget-item" onclick="addFormField('attachment')" draggable="true" ondragstart="dragWidget(event,'attachment')"><span class="icon">&#128230;</span><span>附件</span></div>
<div class="widget-item" onclick="addFormField('department')" draggable="true" ondragstart="dragWidget(event,'department')"><span class="icon">&#127970;</span><span>部门</span></div>
<div class="widget-item" onclick="addFormField('contact')" draggable="true" ondragstart="dragWidget(event,'contact')"><span class="icon">&#128100;</span><span>联系人</span></div>
<div class="widget-item" onclick="addFormField('related_approval')" draggable="true" ondragstart="dragWidget(event,'related_approval')"><span class="icon">&#128279;</span><span>关联审批</span></div>
<div class="widget-item" onclick="addFormField('address')" draggable="true" ondragstart="dragWidget(event,'address')"><span class="icon">&#128205;</span><span>地址</span></div>
<div class="widget-item" onclick="addFormField('location')" draggable="true" ondragstart="dragWidget(event,'location')"><span class="icon">&#128681;</span><span>定位</span></div>
<div class="widget-item" onclick="addFormField('bank_account')" draggable="true" ondragstart="dragWidget(event,'bank_account')"><span class="icon">&#128179;</span><span>收款账户</span></div>
<div class="widget-item" onclick="addFormField('phone')" draggable="true" ondragstart="dragWidget(event,'phone')"><span class="icon">&#128222;</span><span>电话</span></div>
<div class="widget-item" onclick="addFormField('serial_no')" draggable="true" ondragstart="dragWidget(event,'serial_no')"><span class="icon">&#128209;</span><span>流水号</span></div>
</div>
</div>
</div>
<!-- Center: Mobile Preview -->
<div class="form-design-center">
<div class="canvas-wrap">
<div class="mobile-preview" id="mobile-preview" ondragover="allowDrop(event)" ondrop="dropWidget(event)">
<div class="mobile-preview-header">
<span class="mobile-preview-header-title" id="mobile-preview-header">新建审批</span>
<button type="button" class="mobile-preview-clear" onclick="clearAllFields()" title="清空画布">清空画布</button>
</div>
<div class="mobile-preview-body">
<div class="field-list" id="field-list">
<div class="field-list-tip" id="field-empty-tip">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M10.998 17.992v-4.978H6.042v1.962a.5.5 0 0 1-.813.39L1.52 12.404a.5.5 0 0 1 0-.781l3.707-2.964a.501.501 0 0 1 .813.391v1.962h4.957V6.07H9.035a.5.5 0 0 1-.39-.813l2.964-3.704a.501.501 0 0 1 .782 0l2.965 3.704a.5.5 0 0 1-.39.813h-1.964v4.942h4.957V9.05a.5.5 0 0 1 .814-.39l3.706 2.963a.5.5 0 0 1 0 .781l-3.706 2.963a.501.501 0 0 1-.814-.39v-1.962h-4.957v4.978h1.963a.5.5 0 0 1 .391.813l-2.965 3.704a.501.501 0 0 1-.782 0l-2.965-3.704a.5.5 0 0 1 .391-.813h1.963Z" fill="currentColor"/></svg>
<span>点击或拖拽左侧控件至此处</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right: Config Panel -->
<div class="form-design-right" id="form-config-panel">
<div class="config-panel">
<div class="config-panel-title">
<span class="config-panel-title-name" id="config-panel-title">字段设置</span>
</div>
<div class="config-panel-tabs" id="config-panel-tabs">
<div class="config-panel-tab-item config-panel-tab-item-active" onclick="switchConfigTab('basic')">基础设置</div>
<div class="config-panel-tab-item" onclick="switchConfigTab('visibility')">显隐设置</div>
</div>
<div id="config-tab-basic">
<div id="config-basic-content" class="config-panel-empty"></div>
</div>
<div id="config-tab-visibility" class="hidden">
<div id="config-visibility-content" class="config-panel-empty"></div>
</div>
</div>
</div>
</div>
<!-- Flow Design Tab -->
<div id="tab-flow" class="flow-design hidden">
<!-- Left: Node Library -->
<div class="flow-sidebar">
<div class="widget-list-header">节点库</div>
<div class="widget-group">
<div class="widget-group-title" onclick="toggleWidgetGroup(this)">
<span>审批节点</span><span class="arrow">&#9662;</span>
</div>
<div class="widget-group-items">
<div class="widget-item" onclick="addFlowNode('approver')"><span class="icon" style="color:var(--primary)">&#128100;</span><span>审批人</span></div>
<div class="widget-item" onclick="addFlowNode('cc')"><span class="icon" style="color:var(--success)">&#9993;</span><span>抄送人</span></div>
<div class="widget-item" onclick="addFlowNode('handler')"><span class="icon" style="color:var(--warning)">&#128221;</span><span>办理人</span></div>
</div>
</div>
<div class="widget-group">
<div class="widget-group-title" onclick="toggleWidgetGroup(this)">
<span>分支</span><span class="arrow">&#9662;</span>
</div>
<div class="widget-group-items">
<div class="widget-item" onclick="addFlowNode('condition_branch')"><span class="icon" style="color:#8b5cf6">&#8644;</span><span>条件分支</span></div>
<div class="widget-item" onclick="addFlowNode('parallel_branch')"><span class="icon" style="color:#0ea5e9">&#8801;</span><span>并行分支</span></div>
</div>
</div>
</div>
<!-- Center: Flow Canvas -->
<div class="flow-canvas-wrap" id="flow-canvas-wrap">
<div class="flow-canvas" id="flow-canvas">
<div class="flow-editor" id="flow-editor"></div>
</div>
<div class="zoom-controls">
<button class="ud-btn ud-btn--icon ud-btn--link" onclick="zoomFlow(-0.1)" title="缩小">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="m18.743 17.328 3.623 3.624a1 1 0 0 1-1.414 1.414l-3.624-3.624A9.958 9.958 0 0 1 11 21C5.477 21 1 16.523 1 11S5.477 1 11 1s10 4.477 10 10a9.958 9.958 0 0 1-2.257 6.328ZM11 19a8 8 0 0 0 8-8 8 8 0 0 0-8-8 8 8 0 0 0-8 8 8 8 0 0 0 8 8Zm-4-8a1 1 0 0 1 1-1h6a1 1 0 0 1 0 2H8a1 1 0 0 1-1-1Z" fill="currentColor"/></svg>
</button>
<button class="ud-btn ud-btn--link" style="font-size:13px" onclick="resetZoom()">100%</button>
<button class="ud-btn ud-btn--icon ud-btn--link" onclick="zoomFlow(0.1)" title="放大">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="m18.743 17.328 3.622 3.623a.998.998 0 0 1-.002 1.412.998.998 0 0 1-1.412.002l-3.623-3.623A9.958 9.958 0 0 1 11 21C5.477 21 1 16.523 1 11S5.477 1 11 1s10 4.477 10 10a9.958 9.958 0 0 1-2.257 6.328ZM10 10V8a1 1 0 1 1 2 0v2h2a1 1 0 0 1 0 2h-2v2a1 1 0 1 1-2 0v-2H8a1 1 0 1 1 0-2h2Zm1 9a8 8 0 0 0 8-8 8 8 0 0 0-8-8 8 8 0 0 0-8 8 8 8 0 0 0 8 8Z" fill="currentColor"/></svg>
</button>
<button class="ud-btn ud-btn--icon ud-btn--link" onclick="focusFlow()" title="适配画布">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M4 2a2 2 0 0 0-2 2v5a1 1 0 0 0 2 0V4h5a1 1 0 0 0 0-2H4ZM2 20a2 2 0 0 0 2 2h5a1 1 0 1 0 0-2H4v-5a1 1 0 1 0-2 0v5ZM22 4a2 2 0 0 0-2-2h-5a1 1 0 1 0 0 2h5v5a1 1 0 1 0 2 0V4Zm0 16a2 2 0 0 1-2 2h-5a1 1 0 1 1 0-2h5v-5a1 1 0 1 1 2 0v5Zm-10-5a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" fill="currentColor"/></svg>
</button>
</div>
</div>
<!-- Right: Flow Config Panel -->
<div class="approval-editor-drawer" id="flow-config-panel">
<div class="config-panel-empty"></div>
</div>
</div>
<!-- More Settings Tab -->
<div id="tab-more" class="hidden" style="width:100%;padding:40px;display:flex;justify-content:center">
<div style="width:600px;background:var(--surface);border-radius:var(--radius);padding:32px;box-shadow:var(--shadow)">
<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-control-wrapper">
<select class="ant-select">
<option>开启(同一审批人只审批一次)</option>
<option>关闭(每个节点都需要审批)</option>
</select>
</div>
</div>
<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-control-wrapper">
<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" checked><span class="form-other-item-label">审批人必须填写审批意见</span></label>
</div>
</div>
<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-control-wrapper">
<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox"><span class="form-other-item-label">审批同意时需要手写签名</span></label>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="address-regions.js"></script>
<script>
/* ========== DATA & STATE ========== */
let formFields = [];
let selectedFieldId = null;
let flowNodes = [];
let selectedFlowNodeId = null;
let selectedFlowTarget = null;
let selectedFlowBranchId = null;
let selectedFlowTab = 'basic';
let fieldIdCounter = 0;
let flowIdCounter = 0;
let ccIdCounter = 1;
let currentZoom = 1;
let startCcRecipients = [];
let endCcRecipients = [{ id: 'end_cc_1', approverType: 'self', approverName: '', approverLevel: 1, roleName: '', userGroupName: '', formContactField: '', formDeptField: '' }];
let startFormPermissions = {};
let endFormPermissions = {};
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 formContactRuleLabels = { self: '联系人自己', contact_superior: '联系人上级', contact_dept_head: '联系人部门负责人' };
function createPersonEntry(defaults) {
return {
id: 'person_' + (++flowIdCounter),
approverType: 'member',
approverName: '',
approverLevel: 1,
roleName: '',
userGroupName: '',
formContactField: '',
formDeptField: '',
formDeptLevel: 0,
formContactRule: 'self',
formDeptCcMode: null,
refApproverNodeId: '',
continuousEndLevel: 0,
continuousLevelMode: 'bottom_up',
...(defaults || {})
};
}
function createCcRecipient() {
return createPersonEntry({ id: 'cc_' + (++ccIdCounter), approverType: 'member', formDeptCcMode: 'dept_head' });
}
function ensureNodeApprovers(node) {
if (!['approver', 'handler', 'cc'].includes(node.type)) return;
if (!node.approvers) node.approvers = [];
if (node.approvers.length === 0 && node.approverType !== undefined) {
node.approvers.push(createPersonEntry({
approverType: node.approverType || (node.type === 'cc' ? 'superior' : 'member'),
approverName: node.approverName || '',
approverLevel: node.approverLevel || 1,
roleName: node.roleName || '',
userGroupName: node.userGroupName || '',
formContactField: node.formContactField || '',
formDeptField: node.formDeptField || '',
formDeptLevel: node.formDeptLevel !== undefined ? node.formDeptLevel : 0,
formContactRule: node.formContactRule || 'self'
}));
}
node.approvers.forEach(p => {
if (p.formDeptLevel === undefined) p.formDeptLevel = 0;
if (!p.formContactRule) p.formContactRule = 'self';
if (!p.continuousEndLevel && p.continuousEndLevel !== 0) p.continuousEndLevel = 0;
if (!p.continuousLevelMode) p.continuousLevelMode = 'bottom_up';
if (p.refApproverNodeId === undefined) p.refApproverNodeId = '';
if (node.type === 'cc' && !p.formDeptCcMode) p.formDeptCcMode = 'dept_head';
});
if (node.type === 'approver') {
if (!node.sameAsSubmitterAction) node.sameAsSubmitterAction = 'auto_skip';
if (!node.ccRecipients) node.ccRecipients = [];
node.ccRecipients.forEach(p => {
if (p.formDeptLevel === undefined) p.formDeptLevel = 0;
if (!p.formContactRule) p.formContactRule = 'self';
if (!p.formDeptCcMode) p.formDeptCcMode = 'dept_head';
});
}
if (node.emptyApproverAction === 'auto_pass') node.emptyApproverAction = 'auto_approve';
}
function applyConfig(data) {
if (data.formFields) {
formFields = data.formFields;
formFields.forEach(normalizeFormField);
}
if (data.flowNodes) {
flowNodes = data.flowNodes;
flowNodes.forEach(n => {
if (!n.nodeName) n.nodeName = defaultNodeName(n.type);
if (!n.formPermissions) n.formPermissions = {};
if (n.allowAddRemove !== undefined && n.allowAddSign === undefined) {
n.allowAddSign = n.allowAddRemove;
n.allowRemoveSign = n.allowAddRemove;
}
if (n.type === 'parallel_branch' && n.branches) {
normalizeParallelBranches(n);
}
if (n.type === 'condition_branch' && n.branches) {
normalizeConditionBranches(n);
}
ensureNodeApprovers(n);
});
}
if (data.fieldIdCounter) fieldIdCounter = data.fieldIdCounter;
if (data.flowIdCounter) flowIdCounter = data.flowIdCounter;
if (data.ccIdCounter) ccIdCounter = data.ccIdCounter;
if (data.startCcRecipients) startCcRecipients = data.startCcRecipients;
else startCcRecipients = [];
if (data.endCcRecipients) endCcRecipients = data.endCcRecipients;
else if (data.endCcValue) {
endCcRecipients = [{ id: 'end_cc_1', approverType: data.endCcValue.includes('提交人') ? 'self' : 'member', approverName: data.endCcValue.includes('提交人') ? '' : data.endCcValue, approverLevel: 1, roleName: '', userGroupName: '', formContactField: '', formDeptField: '' }];
} else {
endCcRecipients = [{ id: 'end_cc_1', approverType: 'self', approverName: '', approverLevel: 1, roleName: '', userGroupName: '', formContactField: '', formDeptField: '' }];
}
startFormPermissions = data.startFormPermissions || {};
endFormPermissions = data.endFormPermissions || {};
const nameEl = document.getElementById('approval-name');
if (nameEl && data.approvalName) nameEl.value = data.approvalName;
const descEl = document.getElementById('approval-desc');
if (descEl && data.approvalDesc !== undefined) descEl.value = data.approvalDesc;
const submitterTypeEl = document.getElementById('submitter-type');
if (submitterTypeEl && data.submitterType) submitterTypeEl.value = data.submitterType;
const submitterValueEl = document.getElementById('submitter-value');
if (submitterValueEl && data.submitterValue !== undefined) submitterValueEl.value = data.submitterValue;
syncHeaderName();
}
function clearAllFields() {
if (formFields.length && !confirm('确定清空表单画布上的所有字段?')) return;
formFields = [];
selectedFieldId = null;
renderFormFields();
renderFormConfig();
renderFlowConfig();
autoSave();
}
function updateSubmitterValueLive(val) {
const el = document.getElementById('submitter-value');
if (el) el.value = val;
autoSave();
renderFlowEditor();
}
function updateSubmitterType(val) {
const typeEl = document.getElementById('submitter-type');
if (typeEl) typeEl.value = val;
autoSave();
renderFlowEditor();
if (selectedFlowTarget === 'start') renderFlowConfig();
}
function syncSubmitterConfig() {
updateSubmitterType(document.getElementById('submitter-type')?.value || 'all');
}
function getSubmitterConfig() {
return {
type: document.getElementById('submitter-type')?.value || 'all',
value: document.getElementById('submitter-value')?.value || ''
};
}
function getSelectedDetailParent() {
if (!selectedFieldId) return null;
const f = findFormField(selectedFieldId);
if (f?.type === 'detail') return f;
for (const df of formFields) {
if (df.type === 'detail' && df.detailChildren?.some(c => c.id === selectedFieldId)) return df;
}
return null;
}
function syncHeaderName() {
const name = document.getElementById('approval-name')?.value || '审批配置';
const header = document.getElementById('header-approval-name');
if (header) header.textContent = name;
const previewHeader = document.getElementById('mobile-preview-header');
if (previewHeader) previewHeader.textContent = name;
autoSave();
}
function collectConfig() {
return {
projectId: window.APP_PROJECT?.id || 'approval_of_design',
formFields,
flowNodes,
fieldIdCounter,
flowIdCounter,
ccIdCounter,
startCcRecipients,
endCcRecipients,
startFormPermissions,
endFormPermissions,
approvalName: document.getElementById('approval-name')?.value || '新建审批',
approvalDesc: document.getElementById('approval-desc')?.value || '',
submitterType: document.getElementById('submitter-type')?.value || 'all',
submitterValue: document.getElementById('submitter-value')?.value || '',
exportedAt: new Date().toISOString()
};
}
const fieldMeta = {
cloud_doc: { label: '文档', icon: '&#128196;', group: 'doc', hasPlaceholder: false },
single_text: { label: '单行文本', icon: '&#128221;', group: 'text', hasPlaceholder: true },
multi_text: { label: '多行文本', icon: '&#128220;', group: 'text', hasPlaceholder: true },
desc_text: { label: '说明', icon: '&#128172;', group: 'text', hasPlaceholder: true },
number: { label: '数字', icon: '&#128290;', group: 'number', hasPlaceholder: true },
amount: { label: '金额', icon: '&#128176;', group: 'number', hasPlaceholder: true },
formula: { label: '计算公式', icon: '&#128425;', group: 'number', hasPlaceholder: false },
single_choice: { label: '单选', icon: '&#9711;', group: 'option', hasPlaceholder: false },
multi_choice: { label: '多选', icon: '&#9745;', group: 'option', hasPlaceholder: false },
date: { label: '日期', icon: '&#128197;', group: 'date', hasPlaceholder: true },
date_range: { label: '日期区间', icon: '&#128198;', group: 'date', hasPlaceholder: true },
attachment: { label: '附件', icon: '&#128230;', group: 'other', hasPlaceholder: false },
image: { label: '图片/视频', icon: '&#128247;', group: 'other', hasPlaceholder: false },
detail: { label: '明细/表格', icon: '&#128463;', group: 'other', hasPlaceholder: false },
bitable: { label: '引用多维表格', icon: '&#128202;', group: 'other', hasPlaceholder: false },
department: { label: '部门', icon: '&#127970;', group: 'other', hasPlaceholder: true },
contact: { label: '联系人', icon: '&#128100;', group: 'other', hasPlaceholder: true },
address: { label: '地址', icon: '&#128205;', group: 'other', hasPlaceholder: true },
related_approval:{label:'关联审批', icon: '&#128279;', group: 'other', hasPlaceholder: false },
location: { label: '定位', icon: '&#128681;', group: 'other', hasPlaceholder: false },
bank_account: { label: '收款账户', icon: '&#128179;', group: 'other', hasPlaceholder: false },
phone: { label: '电话', icon: '&#128222;', group: 'other', hasPlaceholder: true },
serial_no: { label: '流水号', icon: '&#128209;', group: 'other', hasPlaceholder: false },
member: { label: '成员', icon: '&#128101;', group: 'other', hasPlaceholder: true },
};
const CHOICE_CONDITION_OPERATORS = [
{ value: 'selected', label: '选中' },
{ value: 'not_selected', label: '未选择' }
];
const NUMERIC_CONDITION_OPERATORS = [
{ value: 'equals', label: '等于' },
{ value: 'not_equals', label: '不等于' },
{ value: 'gt', label: '大于' },
{ value: 'lt', label: '小于' }
];
const DATE_FORMAT_OPTIONS = [
{ value: 'yyyy-MM', label: '年-月' },
{ value: 'yyyy-MM-dd', label: '年-月-日' },
{ value: 'yyyy-MM-dd HH:mm', label: '年-月-日 上午/下午' },
{ value: 'yyyy-MM-dd HH:mm:ss', label: '年-月-日 时:分' },
{ value: 'yyyy-MM-dd HH:mm:ss', label: '年-月-日 时:分 时区' }
];
const DURATION_UNIT_OPTIONS = [
{ value: 'day', label: '天' },
{ value: 'hour', label: '小时' },
{ value: 'minute', label: '分钟' }
];
const DATE_RANGE_OPTION_OPTIONS = [
{ value: 'all', label: '所有日期' },
{ value: 'this_week', label: '本周' },
{ value: 'this_month', label: '本月' },
{ value: 'last_7_days', label: '最近7天' },
{ value: 'last_1_month', label: '最近1个月' },
{ value: 'last_3_months', label: '最近3个月' },
{ value: 'last_6_months', label: '最近半年' },
{ value: 'last_1_year', label: '最近一年' },
{ value: 'today', label: '当天' },
{ value: 'today_and_after', label: '当天及以后日期' },
{ value: 'custom', label: '自定义' }
];
const HAS_DEFAULT_VALUE_TYPES = ['single_text', 'date', 'date_range', 'department', 'contact', 'member'];
const DESC_TEXT_PERM_OPTIONS = ['read', 'hide'];
const NORMAL_PERM_OPTIONS = ['read', 'edit', 'hide'];
const BITABLE_REF_FIELD_TYPES = [
{ value: 'text', label: '文本' },
{ value: 'single_choice', label: '单选' },
{ value: 'multi_choice', label: '多选' },
{ value: 'contact', label: '人员' },
{ value: 'number', label: '数字' },
{ value: 'amount', label: '货币' }
];
const SERIAL_DATE_FORMAT_OPTIONS = [
{ value: 'ymd', label: '年月日' },
{ value: 'ym', label: '年月' },
{ value: 'y', label: '年' }
];
const SERIAL_RESET_OPTIONS = [
{ value: 'none', label: '不重置' },
{ value: 'year', label: '每年重置' },
{ value: 'month', label: '每月重置' },
{ value: 'day', label: '每日重置' }
];
const LOCATION_DISPLAY_OPTIONS = [
{ value: 'address_time', label: '定位的地址和时间' },
{ value: 'address', label: '定位的地址' },
{ value: 'coordinate_time', label: '经纬度和时间' }
];
const PHONE_TYPE_OPTIONS = [
{ value: 'mobile_only', label: '仅支持填写手机号码' },
{ value: 'mobile_or_landline', label: '支持填写手机或固话' },
{ value: 'landline_only', label: '仅支持填写固话号码' }
];
const CURRENCY_LIST = [
{ code: 'CNY', name: '人民币' }, { code: 'USD', name: '美元' }, { code: 'EUR', name: '欧元' }, { code: 'GBP', name: '英镑' },
{ code: 'JPY', name: '日元' }, { code: 'HKD', name: '港币' }, { code: 'TWD', name: '新台币' }, { code: 'KRW', name: '韩元' },
{ code: 'SGD', name: '新加坡元' }, { code: 'AUD', name: '澳大利亚元' }, { code: 'CAD', name: '加拿大元' }, { code: 'CHF', name: '瑞士法郎' },
{ code: 'NZD', name: '新西兰元' }, { code: 'SEK', name: '瑞典克朗' }, { code: 'NOK', name: '挪威克朗' }, { code: 'DKK', name: '丹麦克朗' },
{ code: 'RUB', name: '俄罗斯卢布' }, { code: 'INR', name: '印度卢比' }, { code: 'BRL', name: '巴西雷亚尔' }, { code: 'MXN', name: '墨西哥比索' },
{ code: 'ZAR', name: '南非兰特' }, { code: 'AED', name: '阿联酋迪拉姆' }, { code: 'SAR', name: '沙特里亚尔' }, { code: 'THB', name: '泰铢' },
{ code: 'MYR', name: '马来西亚林吉特' }, { code: 'IDR', name: '印尼盾' }, { code: 'PHP', name: '菲律宾比索' }, { code: 'VND', name: '越南盾' },
{ code: 'TRY', name: '土耳其里拉' }, { code: 'PLN', name: '波兰兹罗提' }, { code: 'CZK', name: '捷克克朗' }, { code: 'HUF', name: '匈牙利福林' },
{ code: 'ILS', name: '以色列新谢克尔' }, { code: 'EGP', name: '埃及镑' }, { code: 'NGN', name: '尼日利亚奈拉' }, { code: 'KES', name: '肯尼亚先令' },
{ code: 'AFN', name: '阿富汗尼' }, { code: 'ALL', name: '阿尔巴尼亚列克' }, { code: 'DZD', name: '阿尔及利亚第纳尔' }, { code: 'AOA', name: '安哥拉宽扎' },
{ code: 'XCD', name: '东加勒比元' }, { code: 'ARS', name: '阿根廷比索' }, { code: 'AMD', name: '亚美尼亚德拉姆' }, { code: 'AWG', name: '阿鲁巴弗罗林' },
{ code: 'AZN', name: '阿塞拜疆马纳特' }, { code: 'BSD', name: '巴哈马元' }, { code: 'BHD', name: '巴林第纳尔' }, { code: 'BDT', name: '孟加拉塔卡' },
{ code: 'BBD', name: '巴巴多斯元' }, { code: 'BYN', name: '白俄罗斯卢布' }, { code: 'BZD', name: '伯利兹元' }, { code: 'XOF', name: '西非法郎' },
{ code: 'BMD', name: '百慕大元' }, { code: 'BTN', name: '不丹努尔特鲁姆' }, { code: 'BOB', name: '玻利维亚诺' }, { code: 'BAM', name: '波黑可兑换马克' },
{ code: 'BWP', name: '博茨瓦纳普拉' }, { code: 'BND', name: '文莱元' }, { code: 'BGN', name: '保加利亚列弗' }, { code: 'BIF', name: '布隆迪法郎' },
{ code: 'CVE', name: '佛得角埃斯库多' }, { code: 'KHR', name: '柬埔寨瑞尔' }, { code: 'XAF', name: '中非法郎' }, { code: 'KYD', name: '开曼群岛元' },
{ code: 'CLP', name: '智利比索' }, { code: 'COP', name: '哥伦比亚比索' }, { code: 'KMF', name: '科摩罗法郎' }, { code: 'CDF', name: '刚果法郎' },
{ code: 'CRC', name: '哥斯达黎加科朗' }, { code: 'CUP', name: '古巴比索' }, { code: 'ANG', name: '荷属安的列斯盾' }, { code: 'DJF', name: '吉布提法郎' },
{ code: 'DOP', name: '多米尼加比索' }, { code: 'ERN', name: '厄立特里亚纳克法' }, { code: 'ETB', name: '埃塞俄比亚比尔' }, { code: 'FKP', name: '福克兰群岛镑' },
{ code: 'FJD', name: '斐济元' }, { code: 'XPF', name: '太平洋法郎' }, { code: 'GMD', name: '冈比亚达拉西' }, { code: 'GEL', name: '格鲁吉亚拉里' },
{ code: 'GHS', name: '加纳塞地' }, { code: 'GIP', name: '直布罗陀镑' }, { code: 'GTQ', name: '危地马拉格查尔' }, { code: 'GNF', name: '几内亚法郎' },
{ code: 'GYD', name: '圭亚那元' }, { code: 'HTG', name: '海地古德' }, { code: 'HNL', name: '洪都拉斯伦皮拉' }, { code: 'ISK', name: '冰岛克朗' },
{ code: 'IRR', name: '伊朗里亚尔' }, { code: 'IQD', name: '伊拉克第纳尔' }, { code: 'JMD', name: '牙买加元' }, { code: 'JOD', name: '约旦第纳尔' },
{ code: 'KZT', name: '哈萨克斯坦坚戈' }, { code: 'KPW', name: '朝鲜圆' }, { code: 'KWD', name: '科威特第纳尔' }, { code: 'KGS', name: '吉尔吉斯斯坦索姆' },
{ code: 'LAK', name: '老挝基普' }, { code: 'LBP', name: '黎巴嫩镑' }, { code: 'LSL', name: '莱索托洛蒂' }, { code: 'LRD', name: '利比里亚元' },
{ code: 'LYD', name: '利比亚第纳尔' }, { code: 'MOP', name: '澳门元' }, { code: 'MKD', name: '北马其顿代纳尔' }, { code: 'MGA', name: '马达加斯加阿里亚里' },
{ code: 'MWK', name: '马拉维克瓦查' }, { code: 'MVR', name: '马尔代夫拉菲亚' }, { code: 'MRU', name: '毛里塔尼亚乌吉亚' }, { code: 'MUR', name: '毛里求斯卢比' },
{ code: 'MDL', name: '摩尔多瓦列伊' }, { code: 'MNT', name: '蒙古图格里克' }, { code: 'MAD', name: '摩洛哥迪拉姆' }, { code: 'MZN', name: '莫桑比克梅蒂卡尔' },
{ code: 'MMK', name: '缅甸元' }, { code: 'NAD', name: '纳米比亚元' }, { code: 'NPR', name: '尼泊尔卢比' }, { code: 'NIO', name: '尼加拉瓜科多巴' },
{ code: 'OMR', name: '阿曼里亚尔' }, { code: 'PKR', name: '巴基斯坦卢比' }, { code: 'PAB', name: '巴拿马巴波亚' }, { code: 'PGK', name: '巴布亚新几内亚基那' },
{ code: 'PYG', name: '巴拉圭瓜拉尼' }, { code: 'PEN', name: '秘鲁索尔' }, { code: 'QAR', name: '卡塔尔里亚尔' }, { code: 'RON', name: '罗马尼亚列伊' },
{ code: 'RWF', name: '卢旺达法郎' }, { code: 'SHP', name: '圣赫勒拿镑' }, { code: 'WST', name: '萨摩亚塔拉' }, { code: 'STN', name: '圣多美和普林西比多布拉' },
{ code: 'RSD', name: '塞尔维亚第纳尔' }, { code: 'SCR', name: '塞舌尔卢比' }, { code: 'SLL', name: '塞拉利昂利昂' }, { code: 'SBD', name: '所罗门群岛元' },
{ code: 'SOS', name: '索马里先令' }, { code: 'SSP', name: '南苏丹镑' }, { code: 'LKR', name: '斯里兰卡卢比' }, { code: 'SDG', name: '苏丹镑' },
{ code: 'SRD', name: '苏里南元' }, { code: 'SZL', name: '斯威士兰里兰吉尼' }, { code: 'SYP', name: '叙利亚镑' }, { code: 'TJS', name: '塔吉克斯坦索莫尼' },
{ code: 'TZS', name: '坦桑尼亚先令' }, { code: 'TOP', name: '汤加潘加' }, { code: 'TTD', name: '特立尼达和多巴哥元' }, { code: 'TND', name: '突尼斯第纳尔' },
{ code: 'TMT', name: '土库曼斯坦马纳特' }, { code: 'UGX', name: '乌干达先令' }, { code: 'UAH', name: '乌克兰格里夫纳' }, { code: 'UYU', name: '乌拉圭比索' },
{ code: 'UZS', name: '乌兹别克斯坦苏姆' }, { code: 'VUV', name: '瓦努阿图瓦图' }, { code: 'VES', name: '委内瑞拉玻利瓦尔' }, { code: 'YER', name: '也门里亚尔' },
{ code: 'ZMW', name: '赞比亚克瓦查' }, { code: 'ZWL', name: '津巴布韦元' }, { code: 'BHD', name: '巴林第纳尔' }, { code: 'KHR', name: '柬埔寨瑞尔' },
{ code: 'LAK', name: '老挝基普' }, { code: 'MNT', name: '蒙古图格里克' }, { code: 'NPR', name: '尼泊尔卢比' }, { code: 'SCR', name: '塞舌尔卢比' },
{ code: 'BAM', name: '波黑马克' }, { code: 'HRK', name: '克罗地亚库纳' }, { code: 'EEK', name: '爱沙尼亚克朗' }, { code: 'LVL', name: '拉脱维亚拉特' },
{ code: 'LTL', name: '立陶宛立特' }, { code: 'MTL', name: '马耳他里拉' }, { code: 'SKK', name: '斯洛伐克克朗' }, { code: 'SIT', name: '斯洛文尼亚托拉尔' },
{ code: 'CYP', name: '塞浦路斯镑' }, { code: 'MRO', name: '毛里塔尼亚乌吉亚' }, { code: 'TMM', name: '土库曼斯坦马纳特' }, { code: 'ZMK', name: '赞比亚克瓦查' },
{ code: 'GHC', name: '加纳塞地' }, { code: 'ROL', name: '罗马尼亚列伊' }, { code: 'TRL', name: '土耳其里拉' }, { code: 'VEB', name: '委内瑞拉玻利瓦尔' },
{ code: 'ECS', name: '厄瓜多尔苏克雷' }, { code: 'GWP', name: '几内亚比绍比索' }, { code: 'MGF', name: '马达加斯加法郎' }, { code: 'MZM', name: '莫桑比克梅蒂卡尔' },
{ code: 'STD', name: '圣多美多布拉' }, { code: 'TPE', name: '东帝汶埃斯库多' }, { code: 'XDR', name: '特别提款权' }, { code: 'XAU', name: '黄金' },
{ code: 'XAG', name: '白银' }, { code: 'XPT', name: '铂金' }, { code: 'XPD', name: '钯金' }, { code: 'BTC', name: '比特币' },
{ code: 'ETH', name: '以太坊' }, { code: 'USDT', name: '泰达币' }, { code: 'USDC', name: '美元币' }
];
function renderConfigRadios(fieldId, key, options, current, handler) {
const fn = handler || `updateField('${fieldId}','${key}',this.value)`;
return `<div class="config-radio-group">${options.map(o =>
`<label class="config-radio-item"><input type="radio" name="cfg_${fieldId}_${key}" value="${o.value}" ${String(current) === String(o.value) ? 'checked' : ''} onchange="${fn}"><span>${o.label}</span></label>`
).join('')}</div>`;
}
function renderYesNoRadios(fieldId, key, current) {
const val = current === true || current === 'yes' ? 'yes' : 'no';
return renderConfigRadios(fieldId, key, [{ value: 'yes', label: '是' }, { value: 'no', label: '否' }], val, `updateField('${fieldId}','${key}',this.value==='yes')`);
}
function getDownstreamSingleChoiceFields(field) {
const idx = formFields.findIndex(f => f.id === field.id);
if (idx < 0) return [];
return formFields.slice(idx + 1).filter(f => f.type === 'single_choice');
}
function numberToChineseUpper(n) {
const CN_NUM = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
const CN_UNIT = ['', '拾', '佰', '仟'];
const CN_SEC = ['', '万', '亿', '兆'];
if (n === 0) return '零元整';
const fixed = Math.abs(n).toFixed(2);
const [intStr, decStr] = fixed.split('.');
let result = '';
const intPart = parseInt(intStr, 10);
let secIdx = 0;
let num = intPart;
while (num > 0) {
const section = num % 10000;
if (section > 0) {
let secStr = '';
let u = 0;
let s = section;
while (s > 0) {
const d = s % 10;
if (d > 0) secStr = CN_NUM[d] + CN_UNIT[u] + secStr;
else if (secStr && secStr[0] !== '零') secStr = '零' + secStr;
s = Math.floor(s / 10);
u++;
}
result = secStr.replace(/零+/g, '零').replace(/零$/, '') + CN_SEC[secIdx] + result;
}
num = Math.floor(num / 10000);
secIdx++;
}
result = (n < 0 ? '负' : '') + result + '元';
const jiao = parseInt(decStr[0], 10);
const fen = parseInt(decStr[1], 10);
if (jiao === 0 && fen === 0) result += '整';
else {
if (jiao > 0) result += CN_NUM[jiao] + '角';
if (fen > 0) result += CN_NUM[fen] + '分';
}
return result;
}
function formatAmountPreview(field) {
const n = 123456.123456;
const dec = Math.min(6, Math.max(0, field.decimalPlaces ?? 2));
let [intPart, decPart] = n.toFixed(dec).split('.');
if (field.formatThousands) intPart = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
let result = dec > 0 ? intPart + '.' + decPart : intPart;
const currencies = getFieldCurrencies(field);
if (field.formatUppercase && currencies.includes('CNY')) {
result += '' + numberToChineseUpper(n) + '';
}
return result;
}
function getFieldCurrencies(field) {
if (Array.isArray(field.currencies) && field.currencies.length) return field.currencies;
if (typeof field.currency === 'string' && field.currency) return [field.currency];
return ['CNY'];
}
function getCurrencyTriggerText(currencies) {
if (!currencies.length) return '请选择币种';
return currencies.map(c => {
const item = CURRENCY_LIST.find(x => x.code === c);
return item ? `${item.name}(${c})` : c;
}).join('、');
}
function renderCurrencyMultiSelect(field) {
const currencies = getFieldCurrencies(field);
const panelId = 'currency-panel-' + field.id;
return `<div class="currency-select-wrap" id="currency-wrap-${field.id}">
<button type="button" class="currency-select-trigger ant-input" onclick="toggleCurrencyDropdown('${field.id}', event)">
<span class="currency-select-trigger-text" id="currency-trigger-${field.id}">${esc(getCurrencyTriggerText(currencies))}</span>
<span class="currency-select-arrow">&#9662;</span>
</button>
<div class="currency-select-panel hidden" id="${panelId}" onclick="event.stopPropagation()">
<div class="currency-select-search">
<input type="text" class="ant-input" placeholder="搜索币种名称或代码" oninput="filterCurrencyList('${field.id}', this.value)">
</div>
<div class="currency-select-list" id="currency-list-${field.id}">
${CURRENCY_LIST.map(c => `<label class="currency-select-item" data-search="${esc((c.name + ' ' + c.code).toLowerCase())}">
<input type="checkbox" data-code="${c.code}" ${currencies.includes(c.code) ? 'checked' : ''} onchange="toggleCurrencyItem('${field.id}','${c.code}',this.checked)">
<span>${esc(c.name)} (${c.code})</span>
</label>`).join('')}
</div>
<div class="currency-select-footer">
<button type="button" class="ud-btn ud-btn--text ud-btn--sm" onclick="selectAllCurrencies('${field.id}')">全选</button>
</div>
</div>
</div>`;
}
let openCurrencyDropdownId = null;
function toggleCurrencyDropdown(fieldId, event) {
event.stopPropagation();
const panel = document.getElementById('currency-panel-' + fieldId);
if (!panel) return;
const willOpen = panel.classList.contains('hidden');
closeCurrencyDropdown();
if (willOpen) {
panel.classList.remove('hidden');
openCurrencyDropdownId = fieldId;
const search = panel.querySelector('input');
if (search) { search.value = ''; filterCurrencyList(fieldId, ''); }
}
}
function closeCurrencyDropdown() {
if (openCurrencyDropdownId) {
const panel = document.getElementById('currency-panel-' + openCurrencyDropdownId);
if (panel) panel.classList.add('hidden');
openCurrencyDropdownId = null;
}
}
function filterCurrencyList(fieldId, keyword) {
const list = document.getElementById('currency-list-' + fieldId);
if (!list) return;
const kw = (keyword || '').trim().toLowerCase();
list.querySelectorAll('.currency-select-item').forEach(item => {
const text = item.dataset.search || '';
item.classList.toggle('hidden', kw && !text.includes(kw));
});
}
function updateCurrencyTriggerLabel(fieldId) {
const f = findFormField(fieldId);
const el = document.getElementById('currency-trigger-' + fieldId);
if (f && el) el.textContent = getCurrencyTriggerText(getFieldCurrencies(f));
}
function toggleCurrencyItem(fieldId, code, checked) {
const f = findFormField(fieldId);
if (!f) return;
const currencies = [...getFieldCurrencies(f)];
if (checked && !currencies.includes(code)) currencies.push(code);
else if (!checked) {
const idx = currencies.indexOf(code);
if (idx >= 0) currencies.splice(idx, 1);
}
f.currencies = currencies;
delete f.currency;
updateCurrencyTriggerLabel(fieldId);
refreshAmountPreview(fieldId);
autoSave();
}
function selectAllCurrencies(fieldId) {
const f = findFormField(fieldId);
if (!f) return;
const list = document.getElementById('currency-list-' + fieldId);
const kw = list?.closest('.currency-select-panel')?.querySelector('.currency-select-search input')?.value?.trim().toLowerCase() || '';
if (!kw) {
f.currencies = CURRENCY_LIST.map(c => c.code);
list?.querySelectorAll('.currency-select-item input[type=checkbox]').forEach(cb => { cb.checked = true; });
} else {
const currencies = new Set(getFieldCurrencies(f));
list?.querySelectorAll('.currency-select-item').forEach(item => {
if (!item.classList.contains('hidden')) {
const cb = item.querySelector('input[type=checkbox]');
if (cb?.dataset.code) {
cb.checked = true;
currencies.add(cb.dataset.code);
}
}
});
f.currencies = [...currencies];
}
delete f.currency;
updateCurrencyTriggerLabel(fieldId);
refreshAmountPreview(fieldId);
autoSave();
}
function renderDateRangeOptionSection(field) {
const opt = field.dateRangeOption || 'all';
let html = `<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-control-wrapper">
<select class="ant-select" onchange="updateField('${field.id}','dateRangeOption',this.value)">
${DATE_RANGE_OPTION_OPTIONS.map(o => `<option value="${o.value}" ${opt === o.value ? 'selected' : ''}>${o.label}</option>`).join('')}
</select>`;
if (opt === 'custom') {
html += `<div class="custom-date-range-config" style="margin-top:10px">
<div class="config-inline-row" style="margin-bottom:8px">
<span style="min-width:64px">开始日期</span>
<input type="date" class="ant-input" style="flex:1" value="${esc(field.customDateStart || '')}" onchange="updateField('${field.id}','customDateStart',this.value)">
</div>
<div class="config-inline-row">
<span style="min-width:64px">结束日期</span>
<input type="date" class="ant-input" style="flex:1" value="${esc(field.customDateEnd || '')}" onchange="updateField('${field.id}','customDateEnd',this.value)">
</div>
</div>`;
}
html += `</div></div>`;
return html;
}
function refreshAmountPreview(fieldId) {
const f = findFormField(fieldId);
const el = document.getElementById('amount-preview-' + fieldId);
if (f && el) el.textContent = '预览:' + formatAmountPreview(f);
}
function renderAmountFormatSection(field) {
const decimals = field.decimalPlaces ?? 2;
const preview = formatAmountPreview(field);
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-control-wrapper">
<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${field.formatUppercase ? 'checked' : ''} onchange="updateField('${field.id}','formatUppercase',this.checked)"><span class="form-other-item-label">显示大写数字 <span class="text-muted">(建议当币种为人民币使用)</span></span></label>
<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${field.formatThousands ? 'checked' : ''} onchange="updateField('${field.id}','formatThousands',this.checked)"><span class="form-other-item-label">显示千位分隔符</span></label>
<div class="config-inline-row" style="margin-top:8px">
<span>显示</span>
<input type="number" class="ant-input" style="width:56px" min="0" max="6" value="${decimals}" onchange="updateField('${field.id}','decimalPlaces',Math.min(6,Math.max(0,parseInt(this.value,10)||0)))">
<span>位小数位数</span>
</div>
<div class="amount-format-preview" id="amount-preview-${field.id}">预览:${preview}</div>
</div>
</div>`;
}
function renderSingleChoiceLinkage(field) {
const targets = getDownstreamSingleChoiceFields(field);
if (!targets.length) return '';
if (!field.linkage) field.linkage = { enabled: false, targetId: '', mappings: {} };
let html = `<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-control-wrapper">
<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${field.linkage.enabled ? 'checked' : ''} onchange="updateFieldLinkageEnabled('${field.id}',this.checked)"><span class="form-other-item-label">添加设置联动</span></label>`;
if (field.linkage.enabled) {
html += `<select class="ant-select" style="margin-top:8px" onchange="updateFieldLinkageTarget('${field.id}',this.value)">
<option value="">选择要联动的单选控件</option>
${targets.map(t => `<option value="${t.id}" ${field.linkage.targetId === t.id ? 'selected' : ''}>${esc(t.title)}</option>`).join('')}
</select>`;
const target = targets.find(t => t.id === field.linkage.targetId);
if (target) {
field.options.forEach((opt, si) => {
const selected = field.linkage.mappings[opt] || [];
html += `<div class="linkage-rule-block"><div class="linkage-rule-title">当选择「${esc(opt)}」时,联动控件可选范围:</div>`;
target.options.forEach((to, ti) => {
html += `<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${selected.includes(to) ? 'checked' : ''} onchange="updateFieldLinkageMapping('${field.id}',${si},${ti},this.checked)"><span class="form-other-item-label">${esc(to)}</span></label>`;
});
html += `</div>`;
});
}
}
html += `</div></div>`;
return html;
}
function renderOtherOptionsSection(field) {
const extras = [];
if (field.type === 'image') {
extras.push(`<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${field.mobileOnlyCapture ? 'checked' : ''} onchange="updateField('${field.id}','mobileOnlyCapture',this.checked)"><span class="form-other-item-label">仅限移动端拍摄</span></label>`);
}
if (field.type === 'address') {
extras.push(`<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${field.autoLocate ? 'checked' : ''} onchange="updateField('${field.id}','autoLocate',this.checked)"><span class="form-other-item-label">自动定位</span></label>`);
extras.push(`<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${field.fillDetailAddress ? 'checked' : ''} onchange="updateField('${field.id}','fillDetailAddress',this.checked)"><span class="form-other-item-label">填写详细地址</span></label>`);
}
let printExtra = '';
if (field.type === 'attachment' && field.printable) {
printExtra = '<div class="config-sub-hint">打印附件中的图片</div>';
}
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-control-wrapper">
<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${field.printable ? 'checked' : ''} onchange="updateField('${field.id}','printable',this.checked)"><span class="form-other-item-label">打印</span></label>
${printExtra}
${field.type !== 'desc_text' ? `<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${field.required ? 'checked' : ''} onchange="updateField('${field.id}','required',this.checked)"><span class="form-other-item-label">必填</span></label>` : ''}
${extras.join('')}
</div>
</div>`;
}
function dateFormatLabel(value) {
return DATE_FORMAT_OPTIONS.find(o => o.value === value)?.label || value || '年-月-日';
}
function renderDateFormatSelect(fieldId, current, onchangeFn) {
return `<select class="ant-select" onchange="${onchangeFn}">${DATE_FORMAT_OPTIONS.map(o =>
`<option value="${o.value}" ${current === o.value ? 'selected' : ''}>${o.label}</option>`
).join('')}</select>`;
}
/* ========== INIT ========== */
window.addEventListener('DOMContentLoaded', async () => {
await loadConfig();
syncHeaderName();
renderFormFields();
renderFormConfig();
renderFlowEditor();
renderFlowConfig();
document.addEventListener('click', closeCurrencyDropdown);
});
/* ========== MAIN TAB SWITCH ========== */
function switchMainTab(tab) {
document.querySelectorAll('.create-approval-header-tab-item').forEach(el => {
el.classList.toggle('create-approval-header-tab-item-active', el.dataset.tab === tab);
});
['basic','form','flow','more'].forEach(t => {
document.getElementById('tab-' + t).classList.toggle('hidden', t !== tab);
});
}
/* ========== WIDGET GROUP TOGGLE ========== */
function toggleWidgetGroup(el) {
el.parentElement.classList.toggle('collapsed');
}
/* ========== FORM DESIGN ========== */
function createFormFieldData(type) {
const meta = fieldMeta[type];
if (!meta) return null;
const field = {
id: 'field_' + (++fieldIdCounter),
type,
title: meta.label,
placeholder: meta.hasPlaceholder ? '请输入' : (type === 'date' || type === 'date_range' ? '请选择' : ''),
defaultValue: '',
required: type !== 'desc_text',
printable: true,
options: (type === 'single_choice' || type === 'multi_choice') ? ['选项1', '选项2'] : [],
visibility: []
};
if (type === 'desc_text') field.placeholder = '请输入说明内容';
if (type === 'date') {
field.dateFormat = 'yyyy-MM-dd';
}
if (type === 'date_range') {
field.startTitle = '开始时间';
field.endTitle = '结束时间';
field.durationTitle = '时长';
field.dateFormat = 'yyyy-MM-dd';
field.durationUnit = 'day';
field.allowEditDuration = true;
field.dateRangeOption = 'all';
field.customDateStart = '';
field.customDateEnd = '';
}
if (type === 'cloud_doc') field.archiveLocation = '';
if (type === 'detail') field.detailChildren = [];
if (type === 'number') { field.unit = ''; field.minValue = ''; field.maxValue = ''; }
if (type === 'amount') {
field.currencies = ['CNY'];
field.formatUppercase = false;
field.formatThousands = true;
field.decimalPlaces = 2;
}
if (type === 'formula') {
field.formula = '';
field.formatUppercase = false;
field.formatThousands = true;
field.decimalPlaces = 2;
}
if (type === 'single_choice') field.linkage = { enabled: false, targetId: '', mappings: {} };
if (type === 'bitable') { field.bitableTitle = ''; field.tableName = ''; field.refFields = []; }
if (type === 'image') { field.allowImage = true; field.allowVideo = true; field.mobileOnlyCapture = false; }
if (type === 'department') { field.deptSelectionMode = 'single'; field.deptDisplayMode = 'leaf_only'; }
if (type === 'contact' || type === 'member') { field.contactSelectionMode = 'single'; field.allowSelectSelf = true; }
if (type === 'related_approval') { field.scopeConfig = ''; field.onlyApproved = false; }
if (type === 'address') { field.autoLocate = false; field.showDetailAddress = true; field.fillDetailAddress = false; }
if (type === 'location') field.locationDisplayMode = 'address_time';
if (type === 'bank_account') field.validateBranchRequired = false;
if (type === 'phone') field.phoneType = 'mobile_only';
if (type === 'serial_no') field.serialRules = { fixedText: '', dateFormat: 'ymd', seqDigits: 4, resetMode: 'none' };
return field;
}
function normalizeFormField(field) {
if (field.type === 'date' && field.dateType && !field.dateFormat) {
field.dateFormat = field.dateType === 'datetime' ? 'yyyy-MM-dd HH:mm' : 'yyyy-MM-dd';
}
if (field.type === 'date_range') {
if (!field.startTitle) field.startTitle = '开始时间';
if (!field.endTitle) field.endTitle = '结束时间';
if (!field.durationTitle) field.durationTitle = '时长';
if (!field.dateFormat) field.dateFormat = 'yyyy-MM-dd';
if (!field.durationUnit) field.durationUnit = 'day';
if (field.allowEditDuration === undefined) field.allowEditDuration = true;
if (!field.dateRangeOption) field.dateRangeOption = 'all';
if (field.customDateStart === undefined) field.customDateStart = '';
if (field.customDateEnd === undefined) field.customDateEnd = '';
}
if (field.type === 'cloud_doc' && field.archiveLocation === undefined) field.archiveLocation = '';
if (field.type === 'detail' && !field.detailChildren) field.detailChildren = [];
if (field.type === 'number') {
if (field.unit === undefined) field.unit = '';
if (field.minValue === undefined) field.minValue = '';
if (field.maxValue === undefined) field.maxValue = '';
}
if (field.type === 'amount' || field.type === 'formula') {
if (field.type === 'amount') {
if (!Array.isArray(field.currencies) || !field.currencies.length) {
field.currencies = field.currency ? [field.currency] : ['CNY'];
}
}
if (field.formatUppercase === undefined) field.formatUppercase = false;
if (field.formatThousands === undefined) field.formatThousands = true;
if (field.decimalPlaces === undefined) field.decimalPlaces = 2;
if (field.type === 'formula' && field.formula === undefined) field.formula = '';
}
if (field.type === 'single_choice' && !field.linkage) field.linkage = { enabled: false, targetId: '', mappings: {} };
if (field.type === 'bitable') {
if (field.bitableTitle === undefined) field.bitableTitle = '';
if (field.tableName === undefined) field.tableName = '';
if (!field.refFields) field.refFields = [];
}
if (field.type === 'image') {
if (field.allowImage === undefined) field.allowImage = true;
if (field.allowVideo === undefined) field.allowVideo = true;
if (field.mobileOnlyCapture === undefined) field.mobileOnlyCapture = false;
}
if (field.type === 'department') {
if (!field.deptSelectionMode) field.deptSelectionMode = 'single';
if (!field.deptDisplayMode) field.deptDisplayMode = 'leaf_only';
}
if (field.type === 'contact' || field.type === 'member') {
if (!field.contactSelectionMode) field.contactSelectionMode = 'single';
if (field.allowSelectSelf === undefined) field.allowSelectSelf = true;
}
if (field.type === 'related_approval') {
if (field.scopeConfig === undefined) field.scopeConfig = '';
if (field.onlyApproved === undefined) field.onlyApproved = false;
}
if (field.type === 'address') {
if (field.autoLocate === undefined) field.autoLocate = false;
if (field.showDetailAddress === undefined) field.showDetailAddress = true;
if (field.fillDetailAddress === undefined) field.fillDetailAddress = false;
}
if (field.type === 'location' && !field.locationDisplayMode) field.locationDisplayMode = 'address_time';
if (field.type === 'bank_account' && field.validateBranchRequired === undefined) field.validateBranchRequired = false;
if (field.type === 'phone' && !field.phoneType) field.phoneType = 'mobile_only';
if (field.type === 'serial_no' && !field.serialRules) field.serialRules = { fixedText: '', dateFormat: 'ymd', seqDigits: 4, resetMode: 'none' };
if (field.type === 'desc_text') field.required = false;
if (field.visibility) field.visibility.forEach(group => group.forEach(cond => normalizeVisibilityCondition(cond, field.id)));
if (field.detailChildren) field.detailChildren.forEach(normalizeFormField);
}
function addFormField(type, index) {
if (type === 'detail') {
const field = createFormFieldData(type);
if (!field) return;
if (index !== undefined && index >= 0 && index <= formFields.length) formFields.splice(index, 0, field);
else formFields.push(field);
renderFormFields();
selectFormField(field.id);
renderFlowConfig();
autoSave();
return;
}
const detailParent = getSelectedDetailParent();
if (detailParent) {
addFormFieldToDetail(detailParent.id, type);
return;
}
const field = createFormFieldData(type);
if (!field) return;
if (index !== undefined && index >= 0 && index <= formFields.length) {
formFields.splice(index, 0, field);
} else {
formFields.push(field);
}
renderFormFields();
selectFormField(field.id);
renderFlowConfig();
autoSave();
}
function addFormFieldToDetail(detailId, type) {
if (type === 'detail' || !fieldMeta[type]) return;
const detail = formFields.find(f => f.id === detailId && f.type === 'detail');
if (!detail) return;
if (!detail.detailChildren) detail.detailChildren = [];
const field = createFormFieldData(type);
if (!field) return;
detail.detailChildren.push(field);
renderFormFields();
selectFormField(field.id);
renderFlowConfig();
autoSave();
}
function dragWidget(ev, type) {
ev.dataTransfer.setData('widgetType', type);
}
function allowDrop(ev) { ev.preventDefault(); }
function dropWidget(ev) {
ev.preventDefault();
ev.stopPropagation();
const type = ev.dataTransfer.getData('widgetType');
if (type === 'detail') {
addFormField('detail');
return;
}
const detailBody = ev.target.closest('.widget-view-detail-body');
if (detailBody && type && fieldMeta[type]) {
const detailId = detailBody.dataset.detailId;
if (detailId) {
addFormFieldToDetail(detailId, type);
return;
}
}
if (type && fieldMeta[type]) addFormField(type);
}
function dropWidgetToDetail(ev, detailId) {
ev.preventDefault();
ev.stopPropagation();
ev.currentTarget.classList.remove('detail-drop-over');
const type = ev.dataTransfer.getData('widgetType');
if (type === 'detail') return;
if (type && fieldMeta[type]) addFormFieldToDetail(detailId, type);
}
function detailDragOver(ev) {
ev.preventDefault();
ev.stopPropagation();
ev.currentTarget.classList.add('detail-drop-over');
}
function detailDragLeave(ev) {
ev.currentTarget.classList.remove('detail-drop-over');
}
const FIELD_EMPTY_TIP_HTML = `<div class="field-list-tip" id="field-empty-tip"><svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M10.998 17.992v-4.978H6.042v1.962a.5.5 0 0 1-.813.39L1.52 12.404a.5.5 0 0 1 0-.781l3.707-2.964a.501.501 0 0 1 .813.391v1.962h4.957V6.07H9.035a.5.5 0 0 1-.39-.813l2.964-3.704a.501.501 0 0 1 .782 0l2.965 3.704a.5.5 0 0 1-.39.813h-1.964v4.942h4.957V9.05a.5.5 0 0 1 .814-.39l3.706 2.963a.5.5 0 0 1 0 .781l-3.706 2.963a.501.501 0 0 1-.814-.39v-1.962h-4.957v4.978h1.963a.5.5 0 0 1 .391.813l-2.965 3.704a.501.501 0 0 1-.782 0l-2.965-3.704a.5.5 0 0 1 .391-.813h1.963Z" fill="currentColor"/></svg><span>点击或拖拽左侧控件至此处</span></div>`;
const FLOW_ARROW_DOWN = '<svg class="flow-arrow-down" width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M7 11V3M7 11L4 8M7 11l3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
function fieldNameInput(field, key, placeholder) {
const val = field[key] || '';
return `<input type="text" class="field-name-input" data-field-id="${field.id}" data-field-key="${key}" value="${esc(val)}" placeholder="${esc(placeholder || '字段名称')}" onclick="event.stopPropagation()" onmousedown="event.stopPropagation()" onfocus="event.stopPropagation()" oninput="updateFieldNameLive('${field.id}','${key}',this.value)" onblur="commitFieldName('${field.id}','${key}')">`;
}
function syncFieldTitleInputs(id, key, val) {
document.querySelectorAll(`[data-field-id="${id}"][data-field-key="${key}"]`).forEach(el => {
if (document.activeElement !== el) el.value = val;
});
}
function updateFieldNameLive(id, key, val) {
const f = findFormField(id);
if (!f) return;
f[key] = val;
syncFieldTitleInputs(id, key, val);
if (['title', 'startTitle', 'endTitle', 'durationTitle'].includes(key)) renderFlowConfig();
autoSave();
}
function commitFieldName(id, key) {
renderFormFields();
renderFormConfig();
renderFlowConfig();
autoSave();
}
function renderFormFields() {
const list = document.getElementById('field-list');
if (!list) return;
if (formFields.length === 0) {
list.innerHTML = FIELD_EMPTY_TIP_HTML;
return;
}
list.innerHTML = formFields.map((f, idx) => renderFormFieldItem(f, idx)).join('');
}
function renderFieldPreviewInner(f) {
const meta = fieldMeta[f.type] || { icon: '' };
if (f.type === 'date_range') {
const unitLabel = DURATION_UNIT_OPTIONS.find(o => o.value === f.durationUnit)?.label || '天';
return `
<div class="date-range-view">
<div class="base-view ${f.required ? 'base-view-required' : ''} base-view-has-right-arrow">
<div class="base-view-title">
<div class="base-view-name">${fieldNameInput(f, 'startTitle', '开始时间')}</div>
<div class="base-view-placeholder">${esc(f.placeholder || '请选择')}</div>
</div>
<div class="base-view-right-arrow"><svg width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M9.293 17.293a1 1 0 0 0 1.414 1.414l6-6a1 1 0 0 0 0-1.414l-6-6a1 1 0 0 0-1.414 1.414L14.586 12l-5.293 5.293Z" fill="currentColor"/></svg></div>
</div>
<div class="base-view ${f.required ? 'base-view-required' : ''} base-view-has-right-arrow">
<div class="base-view-title">
<div class="base-view-name">${fieldNameInput(f, 'endTitle', '结束时间')}</div>
<div class="base-view-placeholder">${esc(f.placeholder || '请选择')}</div>
</div>
<div class="base-view-right-arrow"><svg width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M9.293 17.293a1 1 0 0 0 1.414 1.414l6-6a1 1 0 0 0 0-1.414l-6-6a1 1 0 0 0-1.414 1.414L14.586 12l-5.293 5.293Z" fill="currentColor"/></svg></div>
</div>
<div class="base-view ${f.required ? 'base-view-required' : ''}">
<div class="base-view-title">
<div class="base-view-name">${fieldNameInput(f, 'durationTitle', '时长')}<span style="font-weight:400;color:var(--text-caption)">${unitLabel}</span></div>
<div class="base-view-placeholder">自动计算</div>
</div>
</div>
</div>`;
}
if (f.type === 'contact' || f.type === 'member') {
return `
<div class="base-applicant-view">
<div class="base-applicant-view-row">
<div class="base-applicant-view-name">${fieldNameInput(f, 'title', '字段名称')}${f.required ? '<span class="base-view-required"></span>' : ''}</div>
<div class="base-applicant-view-placeholder">${esc(f.placeholder || '请选择')}</div>
</div>
</div>`;
}
const arrow = (f.type === 'date' || f.type === 'department' || f.type === 'address' || f.type === 'single_choice' || f.type === 'multi_choice') ? 'base-view-has-right-arrow' : '';
return `
<div class="base-view ${f.required ? 'base-view-required' : ''} ${arrow}" style="position:relative">
<div class="base-view-title">
<div class="base-view-name">${meta.icon ? `<span style="margin-right:4px">${meta.icon}</span>` : ''}${fieldNameInput(f, 'title', '字段名称')}</div>
<div class="base-view-placeholder">${esc(getFieldPlaceholder(f))}</div>
</div>
${arrow ? '<div class="base-view-right-arrow"><svg width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M9.293 17.293a1 1 0 0 0 1.414 1.414l6-6a1 1 0 0 0 0-1.414l-6-6a1 1 0 0 0-1.414 1.414L14.586 12l-5.293 5.293Z" fill="currentColor"/></svg></div>' : ''}
</div>`;
}
function renderDetailChildItem(c, detailId) {
return `
<div class="field-item ${c.id === selectedFieldId ? 'active' : ''}" data-id="${c.id}" onclick="event.stopPropagation();selectFormField('${c.id}')" draggable="true" ondragstart="dragDetailField(event,'${detailId}','${c.id}')" ondrop="dropDetailField(event,'${detailId}','${c.id}')" ondragover="allowDrop(event)">
<div class="field-item-inner-wrapper">${renderFieldPreviewInner(c)}</div>
<div class="field-actions">
<div class="field-action" onclick="event.stopPropagation();moveDetailField('${detailId}','${c.id}',-1)">&#9650;</div>
<div class="field-action" onclick="event.stopPropagation();moveDetailField('${detailId}','${c.id}',1)">&#9660;</div>
<div class="field-action delete" onclick="event.stopPropagation();deleteDetailField('${detailId}','${c.id}')">&#10005;</div>
</div>
</div>`;
}
function renderFormFieldItem(f, idx) {
const isDetail = f.type === 'detail';
let inner = '';
if (isDetail) {
const children = f.detailChildren || [];
inner = `
<div class="widget-view-detail">
<div class="widget-view-detail-header">${fieldNameInput(f, 'title', '明细名称')}</div>
<div class="widget-view-detail-body" data-detail-id="${f.id}" ondragover="detailDragOver(event)" ondragleave="detailDragLeave(event)" ondrop="dropWidgetToDetail(event,'${f.id}')">
${children.length ? children.map(c => renderDetailChildItem(c, f.id)).join('') : '<div class="widget-view-detail-empty">点击或拖拽左侧控件至此处</div>'}
</div>
</div>`;
} else {
inner = renderFieldPreviewInner(f);
}
return `
<div class="field-item ${f.id === selectedFieldId ? 'active' : ''}" data-id="${f.id}" onclick="selectFormField('${f.id}')" draggable="true" ondragstart="dragField(event,'${f.id}')" ondrop="dropField(event,'${f.id}')" ondragover="allowDrop(event)">
<div class="field-item-inner-wrapper">
${inner}
</div>
<div class="field-actions">
<div class="field-action" onclick="event.stopPropagation();moveField('${f.id}',-1)">&#9650;</div>
<div class="field-action" onclick="event.stopPropagation();moveField('${f.id}',1)">&#9660;</div>
<div class="field-action delete" onclick="event.stopPropagation();deleteField('${f.id}')">&#10005;</div>
</div>
</div>
`;
}
function getFieldPlaceholder(f) {
if (f.type === 'single_choice') return f.options.join(' / ');
if (f.type === 'multi_choice') return '可多选';
if (f.type === 'cloud_doc') return '选择云文档';
if (f.type === 'attachment') return '上传附件';
if (f.type === 'image') return '上传图片';
if (f.type === 'related_approval') return '关联审批单';
if (f.type === 'signature') return '手写签名区域';
if (f.type === 'location') return '获取定位';
if (f.type === 'rating') return '点击评分';
if (f.type === 'bitable') return '选择多维表格';
if (f.type === 'bank_account') return '选择收款账户';
if (f.type === 'phone') return f.placeholder || '请输入电话';
if (f.type === 'serial_no') return '系统自动生成';
if (f.type === 'date') return f.placeholder || dateFormatLabel(f.dateFormat);
return f.placeholder || '请输入';
}
function dragField(ev, id) {
ev.dataTransfer.setData('fieldId', id);
}
function dropField(ev, targetId) {
ev.preventDefault();
const draggedId = ev.dataTransfer.getData('fieldId');
if (!draggedId || draggedId === targetId) return;
const from = formFields.findIndex(f => f.id === draggedId);
const to = formFields.findIndex(f => f.id === targetId);
if (from >= 0 && to >= 0) {
const [f] = formFields.splice(from, 1);
formFields.splice(to, 0, f);
sanitizeAllVisibilityConditions();
renderFormFields();
if (selectedFieldId) renderFormConfig();
autoSave();
}
}
function moveField(id, dir) {
const idx = formFields.findIndex(f => f.id === id);
if (idx < 0) return;
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= formFields.length) return;
[formFields[idx], formFields[newIdx]] = [formFields[newIdx], formFields[idx]];
sanitizeAllVisibilityConditions();
renderFormFields();
if (selectedFieldId) renderFormConfig();
autoSave();
}
function deleteField(id) {
formFields.forEach(f => {
if (f.detailChildren) f.detailChildren = f.detailChildren.filter(c => c.id !== id);
});
formFields = formFields.filter(f => f.id !== id);
sanitizeAllVisibilityConditions();
if (selectedFieldId === id) { selectedFieldId = null; renderFormConfig(); }
renderFormFields();
renderFlowConfig();
autoSave();
}
function deleteDetailField(detailId, childId) {
const detail = formFields.find(f => f.id === detailId);
if (!detail || !detail.detailChildren) return;
detail.detailChildren = detail.detailChildren.filter(c => c.id !== childId);
sanitizeAllVisibilityConditions();
if (selectedFieldId === childId) { selectedFieldId = null; renderFormConfig(); }
renderFormFields();
renderFlowConfig();
autoSave();
}
function moveDetailField(detailId, childId, dir) {
const detail = formFields.find(f => f.id === detailId);
if (!detail || !detail.detailChildren) return;
const idx = detail.detailChildren.findIndex(c => c.id === childId);
if (idx < 0) return;
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= detail.detailChildren.length) return;
[detail.detailChildren[idx], detail.detailChildren[newIdx]] = [detail.detailChildren[newIdx], detail.detailChildren[idx]];
sanitizeAllVisibilityConditions();
renderFormFields();
if (selectedFieldId) renderFormConfig();
autoSave();
}
function dragDetailField(ev, detailId, id) {
ev.stopPropagation();
ev.dataTransfer.setData('detailFieldId', id);
ev.dataTransfer.setData('detailId', detailId);
}
function dropDetailField(ev, detailId, targetId) {
ev.preventDefault();
ev.stopPropagation();
ev.currentTarget.closest('.widget-view-detail-body')?.classList.remove('detail-drop-over');
const draggedId = ev.dataTransfer.getData('detailFieldId');
const srcDetailId = ev.dataTransfer.getData('detailId');
if (!draggedId || draggedId === targetId || srcDetailId !== detailId) return;
const detail = formFields.find(f => f.id === detailId);
if (!detail || !detail.detailChildren) return;
const from = detail.detailChildren.findIndex(c => c.id === draggedId);
const to = detail.detailChildren.findIndex(c => c.id === targetId);
if (from >= 0 && to >= 0) {
const [item] = detail.detailChildren.splice(from, 1);
detail.detailChildren.splice(to, 0, item);
sanitizeAllVisibilityConditions();
renderFormFields();
if (selectedFieldId) renderFormConfig();
autoSave();
}
}
function selectFormField(id) {
selectedFieldId = id;
renderFormFields();
renderFormConfig();
}
function switchConfigTab(tab) {
document.querySelectorAll('#config-panel-tabs .config-panel-tab-item').forEach(t => {
t.classList.toggle('config-panel-tab-item-active', t.textContent.includes(tab === 'basic' ? '基础' : '显隐'));
});
document.getElementById('config-tab-basic').classList.toggle('hidden', tab !== 'basic');
document.getElementById('config-tab-visibility').classList.toggle('hidden', tab !== 'visibility');
}
function renderFormConfig() {
closeCurrencyDropdown();
const field = findFormField(selectedFieldId);
const titleEl = document.getElementById('config-panel-title');
if (!field) {
titleEl.textContent = '';
document.getElementById('config-basic-content').innerHTML = '';
document.getElementById('config-visibility-content').innerHTML = '';
switchConfigTab('basic');
return;
}
titleEl.textContent = fieldMeta[field.type]?.label || field.type;
switchConfigTab('basic');
renderBasicConfig(field);
renderVisibilityConfig(field);
}
function findFormField(id) {
if (!id) return null;
for (const f of formFields) {
if (f.id === id) return f;
if (f.detailChildren) {
const c = f.detailChildren.find(x => x.id === id);
if (c) return c;
}
}
return null;
}
function renderBasicConfig(field) {
normalizeFormField(field);
const meta = fieldMeta[field.type] || { label: field.type, hasPlaceholder: true };
let html = '';
if (field.type === 'number') {
html += `<div class="config-field-hint">身份证、银行卡等超过 15 位数的情况,请使用文本控件</div>`;
}
if (field.type === 'date_range') {
html += `<div class="base-form-item">
<div class="base-form-item-label-parent"><label class="base-form-item-label base-form-item-label-required">控件名称1</label></div>
<div class="base-form-item-control-wrapper"><input type="text" class="ant-input" data-field-id="${field.id}" data-field-key="startTitle" value="${esc(field.startTitle || '开始时间')}" oninput="updateFieldNameLive('${field.id}','startTitle',this.value)" onblur="commitFieldUpdate('${field.id}','startTitle')"></div>
</div>`;
html += `<div class="base-form-item">
<div class="base-form-item-label-parent"><label class="base-form-item-label base-form-item-label-required">控件名称2</label></div>
<div class="base-form-item-control-wrapper"><input type="text" class="ant-input" data-field-id="${field.id}" data-field-key="endTitle" value="${esc(field.endTitle || '结束时间')}" oninput="updateFieldNameLive('${field.id}','endTitle',this.value)" onblur="commitFieldUpdate('${field.id}','endTitle')"></div>
</div>`;
html += `<div class="base-form-item">
<div class="base-form-item-label-parent"><label class="base-form-item-label base-form-item-label-required">控件名称3</label></div>
<div class="base-form-item-control-wrapper"><input type="text" class="ant-input" data-field-id="${field.id}" data-field-key="durationTitle" value="${esc(field.durationTitle || '时长')}" oninput="updateFieldNameLive('${field.id}','durationTitle',this.value)" onblur="commitFieldUpdate('${field.id}','durationTitle')"></div>
</div>`;
} else {
html += `<div class="base-form-item">
<div class="base-form-item-label-parent"><label class="base-form-item-label base-form-item-label-required">标题</label></div>
<div class="base-form-item-control-wrapper"><input type="text" class="ant-input" data-field-id="${field.id}" data-field-key="title" value="${esc(field.title)}" oninput="updateFieldNameLive('${field.id}','title',this.value)" onblur="commitFieldUpdate('${field.id}','title')"></div>
</div>`;
}
if (field.type === 'cloud_doc') {
html += `<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-control-wrapper"><input type="text" class="ant-input" value="${esc(field.archiveLocation || '')}" placeholder="选择或输入归档位置" oninput="updateField('${field.id}','archiveLocation',this.value)"></div>
</div>`;
}
if (meta.hasPlaceholder || field.type === 'date' || field.type === 'date_range') {
html += `<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-control-wrapper"><input type="text" class="ant-input" value="${esc(field.placeholder)}" oninput="updateField('${field.id}','placeholder',this.value)"></div>
</div>`;
}
if (field.type === 'number') {
html += `<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-control-wrapper"><input type="text" class="ant-input" value="${esc(field.unit || '')}" placeholder="如个、kg" oninput="updateField('${field.id}','unit',this.value)"></div>
</div>`;
html += `<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-control-wrapper">
<div class="config-inline-row">
<input type="text" class="ant-input" style="flex:1" value="${esc(field.minValue || '')}" placeholder="最小数值" oninput="updateField('${field.id}','minValue',this.value)">
<span>至</span>
<input type="text" class="ant-input" style="flex:1" value="${esc(field.maxValue || '')}" placeholder="最大数值" oninput="updateField('${field.id}','maxValue',this.value)">
</div>
</div>
</div>`;
}
if (field.type === 'date_range') {
html += `<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-control-wrapper">
<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${field.allowEditDuration ? 'checked' : ''} onchange="updateField('${field.id}','allowEditDuration',this.checked)"><span class="form-other-item-label">允许修改</span></label>
</div>
</div>`;
}
if (field.type === 'single_choice' || field.type === 'multi_choice') {
html += `<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-control-wrapper">
<div id="config-options" style="display:flex;flex-direction:column;gap:6px">`;
field.options.forEach((opt, i) => {
html += `<div style="display:flex;gap:6px"><input type="text" class="ant-input" value="${esc(opt)}" oninput="updateOption('${field.id}',${i},this.value)"><button class="ud-btn ud-btn--link ud-btn--sm" onclick="removeOption('${field.id}',${i})" style="color:var(--danger)">删除</button></div>`;
});
html += `</div>
<button class="ud-btn ud-btn--text ud-btn--sm mt-2" onclick="addOption('${field.id}')">+ 添加选项</button>
</div>
</div>`;
if (field.type === 'single_choice') html += renderSingleChoiceLinkage(field);
}
if (field.type === 'amount') {
html += `<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-control-wrapper">${renderCurrencyMultiSelect(field)}</div>
</div>`;
html += renderAmountFormatSection(field);
}
if (field.type === 'formula') {
html += `<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-control-wrapper"><textarea class="ant-input" rows="2" placeholder="例如: [数字字段1] + [数字字段2]" oninput="updateField('${field.id}','formula',this.value)">${esc(field.formula || '')}</textarea></div>
</div>`;
html += renderAmountFormatSection(field);
}
if (field.type === 'date') {
html += `<div class="base-form-item">
<div class="base-form-item-label-parent"><label class="base-form-item-label base-form-item-label-required">日期格式</label></div>
<div class="base-form-item-control-wrapper">${renderDateFormatSelect(field.id, field.dateFormat || 'yyyy-MM-dd', `updateField('${field.id}','dateFormat',this.value)`)}</div>
</div>`;
}
if (field.type === 'date_range') {
html += `<div class="base-form-item">
<div class="base-form-item-label-parent"><label class="base-form-item-label base-form-item-label-required">日期格式</label></div>
<div class="base-form-item-control-wrapper">${renderDateFormatSelect(field.id, field.dateFormat || 'yyyy-MM-dd', `updateField('${field.id}','dateFormat',this.value)`)}</div>
</div>`;
html += renderDateRangeOptionSection(field);
}
if (field.type === 'bitable') {
html += `<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-control-wrapper"><input type="text" class="ant-input" value="${esc(field.bitableTitle || '')}" placeholder="输入多维表格标题" oninput="updateField('${field.id}','bitableTitle',this.value)"></div>
</div>`;
html += `<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-control-wrapper"><input type="text" class="ant-input" value="${esc(field.tableName || '')}" placeholder="输入表格名称" oninput="updateField('${field.id}','tableName',this.value)"></div>
</div>`;
html += `<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-control-wrapper">`;
(field.refFields || []).forEach((rf, i) => {
html += `<div class="ref-field-row">
<select class="ant-select" style="flex:1" onchange="updateBitableRefFieldType('${field.id}',${i},this.value)">
${BITABLE_REF_FIELD_TYPES.map(t => `<option value="${t.value}" ${rf.type === t.value ? 'selected' : ''}>${t.label}</option>`).join('')}
</select>
<button class="ud-btn ud-btn--link ud-btn--sm" style="color:var(--danger)" onclick="removeBitableRefField('${field.id}',${i})">删除</button>
</div>`;
});
html += `<button class="ud-btn ud-btn--text ud-btn--sm mt-2" onclick="addBitableRefField('${field.id}')">+ 添加引用字段</button></div></div>`;
}
if (field.type === 'image') {
html += `<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-control-wrapper">
<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${field.allowImage !== false ? 'checked' : ''} onchange="updateField('${field.id}','allowImage',this.checked)"><span class="form-other-item-label">图片</span></label>
<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${field.allowVideo !== false ? 'checked' : ''} onchange="updateField('${field.id}','allowVideo',this.checked)"><span class="form-other-item-label">视频</span></label>
</div>
</div>`;
}
if (field.type === 'department') {
html += `<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-control-wrapper">
<select class="ant-select" onchange="updateField('${field.id}','deptSelectionMode',this.value)">
<option value="single" ${field.deptSelectionMode !== 'multiple' ? 'selected' : ''}>可选一个部门</option>
<option value="multiple" ${field.deptSelectionMode === 'multiple' ? 'selected' : ''}>可选多个部门</option>
</select>
</div>
</div>`;
html += `<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-control-wrapper">${renderConfigRadios(field.id, 'deptDisplayMode', [{ value: 'leaf_only', label: '只展示末级部门' }, { value: 'full_hierarchy', label: '展示部门的全部层级' }], field.deptDisplayMode || 'leaf_only')}</div>
</div>`;
}
if (field.type === 'contact' || field.type === 'member') {
html += `<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-control-wrapper">${renderConfigRadios(field.id, 'contactSelectionMode', [{ value: 'single', label: '可选一个' }, { value: 'multiple', label: '可选多个' }], field.contactSelectionMode || 'single')}</div>
</div>`;
html += `<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-control-wrapper">${renderYesNoRadios(field.id, 'allowSelectSelf', field.allowSelectSelf)}</div>
</div>`;
}
if (field.type === 'related_approval') {
html += `<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-control-wrapper"><input type="text" class="ant-input" value="${esc(field.scopeConfig || '')}" placeholder="输入可选审批范围" oninput="updateField('${field.id}','scopeConfig',this.value)"></div>
</div>`;
html += `<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-control-wrapper">
<label class="config-toggle-row"><span>${field.onlyApproved ? '已启用' : '已关闭'}</span><input type="checkbox" ${field.onlyApproved ? 'checked' : ''} onchange="updateField('${field.id}','onlyApproved',this.checked)"></label>
</div>
</div>`;
}
if (field.type === 'address') {
html += `<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-control-wrapper">
<label class="config-toggle-row"><span>${field.showDetailAddress !== false ? '已启用' : '已禁用'}</span><input type="checkbox" ${field.showDetailAddress !== false ? 'checked' : ''} onchange="updateField('${field.id}','showDetailAddress',this.checked)"></label>
</div>
</div>`;
}
if (field.type === 'location') {
html += `<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-control-wrapper">${renderConfigRadios(field.id, 'locationDisplayMode', LOCATION_DISPLAY_OPTIONS, field.locationDisplayMode || 'address_time')}</div>
</div>`;
}
if (field.type === 'bank_account') {
html += `<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-control-wrapper">
<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${field.validateBranchRequired ? 'checked' : ''} onchange="updateField('${field.id}','validateBranchRequired',this.checked)"><span class="form-other-item-label">提交时校验银行支行必填</span></label>
</div>
</div>`;
}
if (field.type === 'phone') {
html += `<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-control-wrapper">${renderConfigRadios(field.id, 'phoneType', PHONE_TYPE_OPTIONS, field.phoneType || 'mobile_only')}</div>
</div>`;
}
if (field.type === 'serial_no') {
const rules = field.serialRules || { fixedText: '', dateFormat: 'ymd', seqDigits: 4, resetMode: 'none' };
html += `<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-control-wrapper">
<div style="margin-bottom:10px"><div class="linkage-rule-title">固定字段(文本)</div>
<input type="text" class="ant-input" value="${esc(rules.fixedText || '')}" placeholder="如SP" oninput="updateSerialRule('${field.id}','fixedText',this.value)"></div>
<div style="margin-bottom:10px"><div class="linkage-rule-title">提交日期</div>
<select class="ant-select" onchange="updateSerialRule('${field.id}','dateFormat',this.value)">
${SERIAL_DATE_FORMAT_OPTIONS.map(o => `<option value="${o.value}" ${rules.dateFormat === o.value ? 'selected' : ''}>${o.label}</option>`).join('')}
</select></div>
<div style="margin-bottom:10px"><div class="linkage-rule-title">自增序号</div>
<select class="ant-select" onchange="updateSerialRule('${field.id}','seqDigits',parseInt(this.value,10))">
${[1,2,3,4,5,6,7,8,9].map(d => `<option value="${d}" ${rules.seqDigits === d ? 'selected' : ''}>${d} 位</option>`).join('')}
</select></div>
<div><div class="linkage-rule-title">重置方式</div>
<select class="ant-select" onchange="updateSerialRule('${field.id}','resetMode',this.value)">
${SERIAL_RESET_OPTIONS.map(o => `<option value="${o.value}" ${rules.resetMode === o.value ? 'selected' : ''}>${o.label}</option>`).join('')}
</select></div>
</div>
</div>`;
}
if (HAS_DEFAULT_VALUE_TYPES.includes(field.type)) {
html += `<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-control-wrapper">
<input type="text" class="ant-input" value="${esc(field.defaultValue || '')}" placeholder="选填,留空表示无默认值" oninput="updateField('${field.id}','defaultValue',this.value)">
</div>
</div>`;
}
html += renderOtherOptionsSection(field);
document.getElementById('config-basic-content').innerHTML = html;
}
function collectVisibilityConditionItemsFromField(f, namePrefix) {
const items = [];
const prefix = namePrefix ? `${namePrefix} · ` : '';
if (['single_choice', 'multi_choice', 'number', 'amount', 'formula'].includes(f.type)) {
items.push({ key: f.id, name: prefix + f.title, fieldType: f.type, field: f });
}
if (f.type === 'date_range') {
items.push({
key: f.id + '_duration',
name: `${prefix}${f.durationTitle || '时长'}${f.title || '日期区间'}`,
fieldType: 'duration',
field: f
});
}
return items;
}
function getVisibilityOrderContext(targetFieldId) {
for (let i = 0; i < formFields.length; i++) {
const f = formFields[i];
if (f.id === targetFieldId) return { type: 'top', precedingFields: formFields.slice(0, i) };
if (f.detailChildren) {
const childIndex = f.detailChildren.findIndex(c => c.id === targetFieldId);
if (childIndex >= 0) {
return {
type: 'detail_child',
precedingTopLevel: formFields.slice(0, i),
precedingSiblings: f.detailChildren.slice(0, childIndex),
parentDetail: f
};
}
}
}
return null;
}
function getVisibilityConditionFieldItems(targetFieldId) {
const ctx = getVisibilityOrderContext(targetFieldId);
if (!ctx) return [];
const items = [];
const appendFromTopLevel = fields => {
fields.forEach(f => {
items.push(...collectVisibilityConditionItemsFromField(f));
if (f.type === 'detail' && f.detailChildren?.length) {
f.detailChildren.forEach(c => {
items.push(...collectVisibilityConditionItemsFromField(c, f.title));
});
}
});
};
if (ctx.type === 'top') {
appendFromTopLevel(ctx.precedingFields);
} else {
appendFromTopLevel(ctx.precedingTopLevel);
ctx.precedingSiblings.forEach(c => {
items.push(...collectVisibilityConditionItemsFromField(c, ctx.parentDetail.title));
});
}
return items;
}
function isVisibilityConditionFieldAllowed(targetFieldId, condFieldKey) {
if (!condFieldKey) return false;
return getVisibilityConditionFieldItems(targetFieldId).some(item => item.key === condFieldKey);
}
function sanitizeAllVisibilityConditions() {
function walk(fields) {
fields.forEach(f => {
if (f.visibility) f.visibility.forEach(group => group.forEach(cond => normalizeVisibilityCondition(cond, f.id)));
if (f.detailChildren) walk(f.detailChildren);
});
}
walk(formFields);
}
function resolveConditionSourceField(condFieldKey) {
if (!condFieldKey) return null;
if (condFieldKey.endsWith('_duration')) return findFormField(condFieldKey.replace(/_duration$/, ''));
return findFormField(condFieldKey);
}
function getConditionFieldType(condFieldKey, sourceField) {
if (condFieldKey?.endsWith('_duration')) return 'duration';
return sourceField?.type || '';
}
function isChoiceConditionType(type) {
return type === 'single_choice' || type === 'multi_choice';
}
function isNumericConditionType(type) {
return type === 'number' || type === 'amount' || type === 'formula' || type === 'duration';
}
function normalizeVisibilityCondition(cond, targetFieldId) {
if (!cond) return;
if (targetFieldId && cond.field && !isVisibilityConditionFieldAllowed(targetFieldId, cond.field)) {
cond.field = '';
cond.value = '';
cond.values = [];
cond.operator = 'selected';
}
if (!Array.isArray(cond.values)) {
cond.values = cond.value ? String(cond.value).split(/[,]/).map(s => s.trim()).filter(Boolean) : [];
}
const sourceField = resolveConditionSourceField(cond.field);
const type = getConditionFieldType(cond.field, sourceField);
if (isChoiceConditionType(type)) {
if (cond.operator === 'equals' || cond.operator === 'contains') cond.operator = 'selected';
else if (cond.operator === 'not_equals') cond.operator = 'not_selected';
if (!['selected', 'not_selected'].includes(cond.operator)) cond.operator = 'selected';
if (type === 'single_choice' && cond.values.length > 1) cond.values = cond.values.slice(0, 1);
} else if (isNumericConditionType(type)) {
if (['selected', 'not_selected', 'contains'].includes(cond.operator)) cond.operator = 'equals';
if (!NUMERIC_CONDITION_OPERATORS.some(o => o.value === cond.operator)) cond.operator = 'equals';
if (cond.values?.length && !cond.value) cond.value = cond.values[0];
}
}
function createEmptyVisibilityCondition() {
return { field: '', operator: 'selected', value: '', values: [] };
}
function renderConditionOperatorSelect(cond, fieldType, fieldId, gi, ci) {
const ops = isChoiceConditionType(fieldType) ? CHOICE_CONDITION_OPERATORS : NUMERIC_CONDITION_OPERATORS;
const op = cond.operator || (isChoiceConditionType(fieldType) ? 'selected' : 'equals');
return `<select onchange="updateCondition('${fieldId}',${gi},${ci},'operator',this.value)" style="width:88px;flex:none">
${ops.map(o => `<option value="${o.value}" ${op === o.value ? 'selected' : ''}>${o.label}</option>`).join('')}
</select>`;
}
function renderConditionValueControl(cond, fieldType, sourceField, fieldId, gi, ci) {
if (!cond.field) return '<div class="condition-value-wrap"><span class="text-muted" style="font-size:12px">请先选择字段</span></div>';
if (isChoiceConditionType(fieldType)) {
const options = sourceField?.options || [];
const values = Array.isArray(cond.values) ? cond.values : [];
if (fieldType === 'multi_choice') {
return `<div class="condition-value-wrap"><div class="condition-choice-values">${options.map((opt, oi) =>
`<label class="condition-choice-item"><input type="checkbox" ${values.includes(opt) ? 'checked' : ''} onchange="toggleConditionChoiceValue('${fieldId}',${gi},${ci},${oi},this.checked,true)"><span>${esc(opt)}</span></label>`
).join('') || '<span class="text-muted" style="font-size:12px">该字段暂无选项</span>'}</div></div>`;
}
const selectedIdx = options.findIndex(opt => values[0] === opt);
return `<div class="condition-value-wrap"><select onchange="updateConditionSingleChoice('${fieldId}',${gi},${ci},this.value)">
<option value="">选择选项</option>
${options.map((opt, oi) => `<option value="${oi}" ${selectedIdx === oi ? 'selected' : ''}>${esc(opt)}</option>`).join('')}
</select></div>`;
}
if (isNumericConditionType(fieldType)) {
return `<div class="condition-value-wrap"><input type="text" placeholder="输入数值" value="${esc(cond.value || '')}" onchange="updateCondition('${fieldId}',${gi},${ci},'value',this.value)"></div>`;
}
return '<div class="condition-value-wrap"></div>';
}
function renderVisibilityConfig(field) {
const container = document.getElementById('config-visibility-content');
const conditionItems = getVisibilityConditionFieldItems(field.id);
if (conditionItems.length === 0) {
container.innerHTML = '<div class="config-panel-hint">暂无可用于条件的字段(仅可选择排在此字段之前的单选、多选、数字、金额、计算公式或日期区间控件)</div>';
return;
}
if (!field.visibility || field.visibility.length === 0) {
container.innerHTML = `<button class="btn-add-condition" style="margin:10px 20px" onclick="addConditionGroup('${field.id}')"><span>+</span> 添加条件组</button>`;
return;
}
let html = '';
(field.visibility || []).forEach((group, gi) => {
html += `<div class="condition-group">
<div class="condition-group-header"><span>条件组 ${gi + 1}</span><button class="condition-remove" onclick="removeConditionGroup('${field.id}',${gi})">&#10005;</button></div>`;
group.forEach((cond, ci) => {
normalizeVisibilityCondition(cond, field.id);
const sourceField = resolveConditionSourceField(cond.field);
const fieldType = getConditionFieldType(cond.field, sourceField);
html += `<div class="condition-row">
<select onchange="updateCondition('${field.id}',${gi},${ci},'field',this.value)"><option value="">选择字段</option>${conditionItems.map(item => `<option value="${item.key}" ${cond.field === item.key ? 'selected' : ''}>${esc(item.name)}</option>`).join('')}</select>
${renderConditionOperatorSelect(cond, fieldType, field.id, gi, ci)}
${renderConditionValueControl(cond, fieldType, sourceField, field.id, gi, ci)}
<button class="condition-remove" onclick="removeCondition('${field.id}',${gi},${ci})" style="margin-top:4px">&#10005;</button>
</div>`;
});
html += `<button class="btn-add-condition" onclick="addCondition('${field.id}',${gi})"><span>+</span> 添加条件</button></div>`;
if (gi < field.visibility.length - 1) html += '<div class="or-divider">或</div>';
});
html += `<button class="btn-add-condition" style="margin:10px 20px" onclick="addConditionGroup('${field.id}')"><span>+</span> 添加条件组</button>`;
container.innerHTML = html;
}
function commitFieldUpdate(id, key) {
renderFormFields();
renderFormConfig();
if (['title', 'startTitle', 'endTitle', 'durationTitle'].includes(key)) renderFlowConfig();
autoSave();
}
function updateField(id, key, val) {
const f = findFormField(id);
if (!f) return;
f[key] = val;
const liveTextKeys = ['title', 'startTitle', 'endTitle', 'durationTitle', 'placeholder', 'defaultValue', 'unit', 'minValue', 'maxValue', 'formula', 'bitableTitle', 'tableName', 'scopeConfig', 'archiveLocation', 'customDateStart', 'customDateEnd'];
if (liveTextKeys.includes(key)) {
if (['title', 'startTitle', 'endTitle', 'durationTitle'].includes(key)) {
syncFieldTitleInputs(id, key, val);
renderFlowConfig();
}
autoSave();
return;
}
const previewKeys = ['formatUppercase', 'formatThousands', 'decimalPlaces', 'currency'];
if (previewKeys.includes(key)) {
refreshAmountPreview(id);
autoSave();
return;
}
const configOnlyKeys = ['allowSelectSelf', 'deptDisplayMode', 'deptSelectionMode', 'contactSelectionMode', 'onlyApproved', 'autoLocate', 'showDetailAddress', 'fillDetailAddress', 'locationDisplayMode', 'validateBranchRequired', 'phoneType', 'allowImage', 'allowVideo', 'mobileOnlyCapture', 'printable', 'required', 'allowEditDuration', 'dateFormat', 'dateRangeOption'];
if (configOnlyKeys.includes(key)) {
if (key === 'deptSelectionMode' || key === 'contactSelectionMode') renderFlowConfig();
renderFormConfig();
autoSave();
return;
}
renderFormFields();
renderFormConfig();
autoSave();
}
function updateFieldLinkageEnabled(id, enabled) {
const f = findFormField(id);
if (!f) return;
if (!f.linkage) f.linkage = { enabled: false, targetId: '', mappings: {} };
f.linkage.enabled = enabled;
renderFormConfig();
autoSave();
}
function updateFieldLinkageTarget(id, targetId) {
const f = findFormField(id);
if (!f) return;
if (!f.linkage) f.linkage = { enabled: true, targetId: '', mappings: {} };
f.linkage.targetId = targetId;
f.linkage.mappings = {};
renderFormConfig();
autoSave();
}
function updateFieldLinkageMapping(id, sourceIdx, targetIdx, checked) {
const f = findFormField(id);
if (!f?.linkage) return;
const sourceOpt = f.options?.[sourceIdx];
const target = getDownstreamSingleChoiceFields(f).find(t => t.id === f.linkage.targetId);
if (!sourceOpt || !target) return;
const targetOpt = target.options?.[targetIdx];
if (!targetOpt) return;
if (!f.linkage.mappings[sourceOpt]) f.linkage.mappings[sourceOpt] = [];
const arr = f.linkage.mappings[sourceOpt];
if (checked && !arr.includes(targetOpt)) arr.push(targetOpt);
else if (!checked) f.linkage.mappings[sourceOpt] = arr.filter(x => x !== targetOpt);
autoSave();
}
function addBitableRefField(id) {
const f = findFormField(id);
if (!f) return;
if (!f.refFields) f.refFields = [];
f.refFields.push({ type: 'text' });
renderFormConfig();
autoSave();
}
function removeBitableRefField(id, idx) {
const f = findFormField(id);
if (f?.refFields) {
f.refFields.splice(idx, 1);
renderFormConfig();
autoSave();
}
}
function updateBitableRefFieldType(id, idx, type) {
const f = findFormField(id);
if (f?.refFields?.[idx]) {
f.refFields[idx].type = type;
autoSave();
}
}
function updateSerialRule(id, key, val) {
const f = findFormField(id);
if (!f) return;
if (!f.serialRules) f.serialRules = { fixedText: '', dateFormat: 'ymd', seqDigits: 4, resetMode: 'none' };
f.serialRules[key] = val;
autoSave();
}
function updateOption(id, idx, val) {
const f = findFormField(id);
if (f && f.options) { f.options[idx] = val; renderFormFields(); autoSave(); }
}
function addOption(id) {
const f = findFormField(id);
if (f && f.options) { f.options.push('选项' + (f.options.length + 1)); renderFormConfig(); autoSave(); }
}
function removeOption(id, idx) {
const f = findFormField(id);
if (f && f.options && f.options.length > 1) { f.options.splice(idx, 1); renderFormConfig(); autoSave(); }
}
function addConditionGroup(fieldId) {
const f = findFormField(fieldId);
if (!f) return;
if (!f.visibility) f.visibility = [];
f.visibility.push([createEmptyVisibilityCondition()]);
renderVisibilityConfig(f);
autoSave();
}
function addCondition(fieldId, gi) {
const f = findFormField(fieldId);
if (f && f.visibility && f.visibility[gi]) { f.visibility[gi].push(createEmptyVisibilityCondition()); renderVisibilityConfig(f); autoSave(); }
}
function removeCondition(fieldId, gi, ci) {
const f = findFormField(fieldId);
if (f && f.visibility && f.visibility[gi]) { f.visibility[gi].splice(ci, 1); if (f.visibility[gi].length === 0) f.visibility.splice(gi, 1); renderVisibilityConfig(f); autoSave(); }
}
function removeConditionGroup(fieldId, gi) {
const f = findFormField(fieldId);
if (f && f.visibility) { f.visibility.splice(gi, 1); renderVisibilityConfig(f); autoSave(); }
}
function updateCondition(fieldId, gi, ci, key, val) {
const f = findFormField(fieldId);
if (!f?.visibility?.[gi]?.[ci]) return;
const cond = f.visibility[gi][ci];
cond[key] = val;
if (key === 'field') {
const sourceField = resolveConditionSourceField(val);
const fieldType = getConditionFieldType(val, sourceField);
cond.operator = isChoiceConditionType(fieldType) ? 'selected' : 'equals';
cond.value = '';
cond.values = [];
renderVisibilityConfig(f);
} else if (key === 'operator') {
renderVisibilityConfig(f);
}
autoSave();
}
function toggleConditionChoiceValue(fieldId, gi, ci, optionIndex, checked, isMulti) {
const f = findFormField(fieldId);
const cond = f?.visibility?.[gi]?.[ci];
if (!cond) return;
const sourceField = resolveConditionSourceField(cond.field);
const opt = sourceField?.options?.[optionIndex];
if (!opt) return;
if (!Array.isArray(cond.values)) cond.values = [];
if (isMulti) {
if (checked && !cond.values.includes(opt)) cond.values.push(opt);
else if (!checked) cond.values = cond.values.filter(v => v !== opt);
} else {
cond.values = checked ? [opt] : [];
renderVisibilityConfig(f);
}
autoSave();
}
function updateConditionSingleChoice(fieldId, gi, ci, optionIndex) {
const f = findFormField(fieldId);
const cond = f?.visibility?.[gi]?.[ci];
if (!cond) return;
const sourceField = resolveConditionSourceField(cond.field);
const idx = parseInt(optionIndex, 10);
const opt = Number.isNaN(idx) || idx < 0 ? null : sourceField?.options?.[idx];
cond.values = opt ? [opt] : [];
autoSave();
}
function esc(str) {
if (str === null || str === undefined) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* ========== BRANCH CONDITION HELPERS ========== */
const BRANCH_SUBMITTER_FIELD = '__submitter__';
const BRANCH_SUBMITTER_OPERATORS = [{ value: 'in', label: '属于' }, { value: 'not_in', label: '不属于' }];
const BRANCH_SINGLE_OPERATORS = [{ value: 'equals', label: '等于' }, { value: 'not_equals', label: '不等于' }];
const BRANCH_MULTI_OPERATORS = [
{ value: 'exact', label: '完全匹配' },
{ value: 'contains_any', label: '包含以下任意' },
{ value: 'not_contains', label: '不包含' }
];
const BRANCH_DATE_OPERATORS = [
{ value: 'before', label: '早于' },
{ value: 'after', label: '晚于' },
{ value: 'equals', label: '等于' }
];
const BRANCH_NUMERIC_OPERATORS = [
{ value: 'lt', label: '小于' },
{ value: 'lte', label: '小于等于' },
{ value: 'gt', label: '大于' },
{ value: 'gte', label: '大于等于' },
{ value: 'equals', label: '等于' }
];
const BRANCH_DEPT_OPERATORS = [
{ value: 'contains_any', label: '包含以下任意' },
{ value: 'not_contains', label: '不包含' }
];
function createDefaultBranch(isElse, label, withDefaultConditionGroup) {
const groups = isElse ? [] : (withDefaultConditionGroup ? [[createEmptyBranchCondition()]] : []);
return { id: 'branch_' + (++flowIdCounter), label, nodes: [], isElse: !!isElse, conditionGroups: groups };
}
function normalizeBranchData(branch, isCondition) {
if (branch.conditions && !branch.conditionGroups) {
branch.conditionGroups = branch.conditions.length ? [branch.conditions.map(c => ({ ...c, values: c.values || [] }))] : [];
delete branch.conditions;
}
if (!branch.conditionGroups) branch.conditionGroups = [];
if (isCondition && !branch.isElse && !branch.conditionGroups.length) {
branch.conditionGroups = [[createEmptyBranchCondition()]];
}
if (isCondition) branch.conditionGroups.forEach(g => g.forEach(c => normalizeBranchCondition(c)));
}
function normalizeConditionBranches(node) {
let elseBranch = (node.branches || []).find(b => b.isElse || b.label === '其他情况');
node.branches = (node.branches || []).filter(b => !b.isElse && b.label !== '其他情况');
if (!node.branches.length) node.branches.push(createDefaultBranch(false, '条件分支 1', true));
node.branches.forEach((b, i) => {
if (!b.label) b.label = '条件分支 ' + (i + 1);
normalizeBranchData(b, true);
});
if (!elseBranch) elseBranch = createDefaultBranch(true, '其他情况');
elseBranch.label = '其他情况';
elseBranch.isElse = true;
elseBranch.conditionGroups = [];
node.branches.push(elseBranch);
}
function normalizeParallelBranches(node) {
let elseBranch = (node.branches || []).find(b => b.isElse || b.label === '其他情况');
node.branches = (node.branches || []).filter(b => !b.isElse && b.label !== '其他情况');
if (!node.branches.length) node.branches.push(createDefaultBranch(false, '并行分支1', false));
node.branches.forEach((b, i) => {
if (b.isElse) return;
if (!b.label || /^分支\s/.test(b.label)) b.label = '并行分支' + (i + 1);
else if (/^并行分支\s/.test(b.label)) b.label = b.label.replace(/\s+/g, '');
normalizeBranchData(b, false);
});
if (!elseBranch) elseBranch = createDefaultBranch(true, '其他情况');
elseBranch.label = '其他情况';
elseBranch.isElse = true;
elseBranch.conditionGroups = [];
node.branches.push(elseBranch);
}
function createBranchFlowNode(type) {
const isCondition = type === 'condition_branch';
const label = isCondition ? '条件分支 1' : '并行分支1';
return {
id: 'flow_' + (++flowIdCounter),
type,
nodeName: isCondition ? '条件分支' : '并行分支',
branches: [createDefaultBranch(false, label, isCondition), createDefaultBranch(true, '其他情况')]
};
}
function getBranchConditionFieldItems() {
const items = [{ key: BRANCH_SUBMITTER_FIELD, name: '提交人', fieldType: 'submitter', field: null }];
function appendField(f, prefix) {
if (!f.required) return;
const name = prefix ? prefix + ' · ' + f.title : f.title;
if (f.type === 'date_range') {
items.push({ key: f.id + '_duration', name: name + ' · ' + (f.durationTitle || '时长'), fieldType: 'duration', field: f });
return;
}
const types = ['single_choice', 'multi_choice', 'date', 'number', 'amount', 'formula', 'contact', 'member', 'department', 'address'];
if (types.includes(f.type)) items.push({ key: f.id, name, fieldType: f.type, field: f });
}
formFields.forEach(f => {
if (f.type === 'detail') (f.detailChildren || []).forEach(c => appendField(c, f.title));
else appendField(f, '');
});
return items;
}
function resolveBranchConditionField(key) {
if (key === BRANCH_SUBMITTER_FIELD) return { fieldType: 'submitter', field: null };
if (key?.endsWith('_duration')) return { fieldType: 'duration', field: findFormField(key.replace(/_duration$/, '')) };
const f = findFormField(key);
return { fieldType: f?.type || '', field: f };
}
function isBranchSubmitterType(t) { return t === 'submitter' || t === 'contact' || t === 'member'; }
function isBranchMultiChoiceType(t) { return t === 'multi_choice'; }
function isBranchSingleChoiceType(t) { return t === 'single_choice'; }
function isBranchDateType(t) { return t === 'date'; }
function isBranchNumericType(t) { return ['number', 'amount', 'formula', 'duration'].includes(t); }
function isBranchDeptType(t) { return t === 'department'; }
function isBranchAddressType(t) { return t === 'address'; }
function createEmptyBranchCondition() {
return { field: '', operator: 'in', value: '', values: [], currency: '', address: { country: '中国', province: '', city: '', district: '' } };
}
function normalizeBranchCondition(cond) {
if (!cond) return;
if (!Array.isArray(cond.values)) cond.values = cond.value ? String(cond.value).split(/[,]/).map(s => s.trim()).filter(Boolean) : [];
if (!cond.address) cond.address = { country: '中国', province: '', city: '', district: '' };
const { fieldType } = resolveBranchConditionField(cond.field);
if (isBranchSubmitterType(fieldType)) {
if (!['in', 'not_in'].includes(cond.operator)) cond.operator = 'in';
} else if (isBranchSingleChoiceType(fieldType)) {
if (!['equals', 'not_equals'].includes(cond.operator)) cond.operator = 'equals';
} else if (isBranchMultiChoiceType(fieldType)) {
if (!['exact', 'contains_any', 'not_contains'].includes(cond.operator)) cond.operator = 'contains_any';
} else if (isBranchDateType(fieldType)) {
if (!['before', 'after', 'equals'].includes(cond.operator)) cond.operator = 'equals';
} else if (isBranchNumericType(fieldType)) {
if (!['lt', 'lte', 'gt', 'gte', 'equals'].includes(cond.operator)) cond.operator = 'equals';
} else if (isBranchDeptType(fieldType)) {
if (!['contains_any', 'not_contains'].includes(cond.operator)) cond.operator = 'contains_any';
} else if (isBranchAddressType(fieldType)) {
cond.operator = 'select_address';
if (cond.address) applyCountryAddressDefaults(cond.address.country || '中国', cond.address);
}
}
function getBranchConditionOperators(fieldType) {
if (isBranchSubmitterType(fieldType)) return BRANCH_SUBMITTER_OPERATORS;
if (isBranchSingleChoiceType(fieldType)) return BRANCH_SINGLE_OPERATORS;
if (isBranchMultiChoiceType(fieldType)) return BRANCH_MULTI_OPERATORS;
if (isBranchDateType(fieldType)) return BRANCH_DATE_OPERATORS;
if (isBranchNumericType(fieldType)) return BRANCH_NUMERIC_OPERATORS;
if (isBranchDeptType(fieldType)) return BRANCH_DEPT_OPERATORS;
return BRANCH_SINGLE_OPERATORS;
}
function findBranchById(branchId) {
function walk(nodes) {
for (const n of nodes) {
if (n.branches) {
for (const b of n.branches) {
if (b.id === branchId) return { branch: b, parent: n };
const inner = walk(b.nodes);
if (inner) return inner;
}
}
}
return null;
}
return walk(flowNodes);
}
function findNodeContainer(nodeId) {
function walk(nodes) {
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].id === nodeId) return { nodes, index: i };
if (nodes[i].branches) {
for (const b of nodes[i].branches) {
const found = walk(b.nodes);
if (found) return found;
}
}
}
return null;
}
return walk(flowNodes);
}
function insertNodeAfterPrev(prevId, node) {
if (prevId === 'start') { flowNodes.unshift(node); return true; }
const pid = String(prevId).replace(/^after_/, '');
const rootIdx = flowNodes.findIndex(n => n.id === pid);
if (rootIdx >= 0) { flowNodes.splice(rootIdx + 1, 0, node); return true; }
const branchCtx = findBranchById(pid);
if (branchCtx) { branchCtx.branch.nodes.push(node); return true; }
const ctx = findNodeContainer(pid);
if (ctx) { ctx.nodes.splice(ctx.index + 1, 0, node); return true; }
return false;
}
function getSelectedBranchContext() {
if (!selectedFlowBranchId) return null;
const parts = selectedFlowBranchId.split('::');
if (parts.length !== 2) return null;
const node = findFlowNode(flowNodes, parts[0]);
const branch = node?.branches?.find(b => b.id === parts[1]);
return node && branch ? { node, branch } : null;
}
function renderBranchConditionOperatorSelect(cond, fieldType, nodeId, branchId, gi, ci) {
const ops = getBranchConditionOperators(fieldType);
const op = cond.operator || ops[0]?.value || 'equals';
return `<select style="width:96px;flex:none" onchange="updateBranchCondition('${nodeId}','${branchId}',${gi},${ci},'operator',this.value)">${ops.map(o => `<option value="${o.value}" ${op === o.value ? 'selected' : ''}>${o.label}</option>`).join('')}</select>`;
}
function renderBranchConditionValueControl(cond, fieldType, sourceField, nodeId, branchId, gi, ci) {
if (!cond.field) return '<div class="condition-value-wrap"><span class="text-muted" style="font-size:12px">请先选择字段</span></div>';
if (isBranchSubmitterType(fieldType)) {
return `<div class="condition-value-wrap"><input type="text" class="ant-input" placeholder="部门/人员/角色/用户组" value="${esc(cond.value || '')}" onchange="updateBranchCondition('${nodeId}','${branchId}',${gi},${ci},'value',this.value)"></div>`;
}
if (isBranchSingleChoiceType(fieldType)) {
const options = sourceField?.options || [];
const selectedIdx = options.findIndex(opt => (cond.values || [])[0] === opt);
return `<div class="condition-value-wrap"><select onchange="updateBranchConditionSingleChoice('${nodeId}','${branchId}',${gi},${ci},this.value)"><option value="">选择选项</option>${options.map((opt, oi) => `<option value="${oi}" ${selectedIdx === oi ? 'selected' : ''}>${esc(opt)}</option>`).join('')}</select></div>`;
}
if (isBranchMultiChoiceType(fieldType)) {
const options = sourceField?.options || [];
const values = Array.isArray(cond.values) ? cond.values : [];
return `<div class="condition-value-wrap"><div class="condition-choice-values">${options.map((opt, oi) =>
`<label class="condition-choice-item"><input type="checkbox" ${values.includes(opt) ? 'checked' : ''} onchange="toggleBranchConditionChoice('${nodeId}','${branchId}',${gi},${ci},${oi},this.checked)"><span>${esc(opt)}</span></label>`
).join('') || '<span class="text-muted" style="font-size:12px">该字段暂无选项</span>'}</div></div>`;
}
if (isBranchDateType(fieldType)) {
return `<div class="condition-value-wrap"><input type="date" class="ant-input" value="${esc(cond.value || '')}" onchange="updateBranchCondition('${nodeId}','${branchId}',${gi},${ci},'value',this.value)"></div>`;
}
if (isBranchNumericType(fieldType)) {
let html = `<div class="condition-value-wrap" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">`;
html += `<input type="text" class="ant-input" style="flex:1;min-width:80px" placeholder="输入数值" value="${esc(cond.value || '')}" oninput="this.value=this.value.replace(/[^0-9.\\-]/g,'')" onchange="updateBranchCondition('${nodeId}','${branchId}',${gi},${ci},'value',this.value)">`;
if (fieldType === 'amount') {
const currencies = sourceField?.currencies || ['CNY'];
html += `<select style="width:88px;flex:none" onchange="updateBranchCondition('${nodeId}','${branchId}',${gi},${ci},'currency',this.value)">${currencies.map(c => `<option value="${c}" ${cond.currency === c ? 'selected' : ''}>${esc(c)}</option>`).join('')}</select>`;
}
if (fieldType === 'duration') html += `<span class="text-muted" style="font-size:12px;white-space:nowrap">天</span>`;
html += `</div>`;
return html;
}
if (isBranchDeptType(fieldType)) {
const values = Array.isArray(cond.values) ? cond.values : [];
const text = values.join('');
return `<div class="condition-value-wrap"><input type="text" class="ant-input" placeholder="输入部门,多个用逗号分隔" value="${esc(text)}" onchange="updateBranchConditionDeptValues('${nodeId}','${branchId}',${gi},${ci},this.value)"></div>`;
}
if (isBranchAddressType(fieldType)) {
const raw = cond.address || { country: '中国', province: '', city: '', district: '' };
const addr = applyCountryAddressDefaults(raw.country, { ...raw });
const meta = getCountryMeta(addr.country);
const provinces = getProvincesForCountry(addr.country);
const cities = getCitiesForCountry(addr.country, addr.province);
const districts = getDistrictsForCountry(addr.country, addr.province, addr.city);
const gridCols = (meta.hasRegion ? 1 : 0) + 1 + (meta.hasDistrict ? 1 : 0);
let regionSelect = '';
if (meta.hasRegion) {
if (meta.fixedRegion) {
regionSelect = `<select class="ant-select" disabled title="省份/州"><option>${esc(meta.fixedRegion)}</option></select>`;
} else {
regionSelect = `<select class="ant-select" onchange="updateBranchConditionAddress('${nodeId}','${branchId}',${gi},${ci},'province',this.value)"><option value="">省份/州</option>${provinces.map(p => `<option value="${esc(p)}" ${addr.province === p ? 'selected' : ''}>${esc(p)}</option>`).join('')}</select>`;
}
}
const citySelect = `<select class="ant-select" onchange="updateBranchConditionAddress('${nodeId}','${branchId}',${gi},${ci},'city',this.value)"><option value="">城市</option>${cities.map(c => `<option value="${esc(c)}" ${addr.city === c ? 'selected' : ''}>${esc(c)}</option>`).join('')}</select>`;
const districtSelect = meta.hasDistrict
? `<select class="ant-select" onchange="updateBranchConditionAddress('${nodeId}','${branchId}',${gi},${ci},'district',this.value)"><option value="">区/县</option>${districts.map(d => `<option value="${esc(d)}" ${addr.district === d ? 'selected' : ''}>${esc(d)}</option>`).join('')}</select>`
: '';
return `<div class="condition-value-wrap" style="display:flex;flex-direction:column;gap:8px;width:100%">
<label class="form-other-item"><input type="radio" name="addr_mode_${nodeId}_${branchId}_${gi}_${ci}" checked><span class="form-other-item-label">选择地址</span></label>
<select class="ant-select" onchange="updateBranchConditionAddress('${nodeId}','${branchId}',${gi},${ci},'country',this.value)">${ADDRESS_COUNTRIES.map(c => `<option ${addr.country === c ? 'selected' : ''}>${esc(c)}</option>`).join('')}</select>
<div style="display:grid;grid-template-columns:repeat(${gridCols},1fr);gap:6px">
${regionSelect}${citySelect}${districtSelect}
</div>
</div>`;
}
return '<div class="condition-value-wrap"></div>';
}
function renderBranchConditionGroups(node, branch) {
if (branch.isElse) return '<div class="text-muted" style="font-size:12px;padding:0 20px">当不满足其他分支条件时,默认进入此分支</div>';
const items = getBranchConditionFieldItems();
if (!items.length) return '<div class="config-panel-hint">请先在表单设计中添加必填字段</div>';
if (!branch.conditionGroups || !branch.conditionGroups.length) {
return `<button class="btn-add-condition" style="margin:10px 20px" onclick="addBranchConditionGroup('${node.id}','${branch.id}')"><span>+</span> 添加条件组</button>`;
}
let html = '<div class="top-tips"><span>组内条件需全部满足</span><span class="text-muted">组与组之间</span></div>';
branch.conditionGroups.forEach((group, gi) => {
html += `<div class="condition-group"><div class="condition-group-header"><span>条件组 ${gi + 1}</span><button class="condition-remove" onclick="removeBranchConditionGroup('${node.id}','${branch.id}',${gi})">&#10005;</button></div>`;
group.forEach((cond, ci) => {
normalizeBranchCondition(cond);
const { fieldType, field } = resolveBranchConditionField(cond.field);
html += `<div class="condition-row">
<select onchange="updateBranchCondition('${node.id}','${branch.id}',${gi},${ci},'field',this.value)"><option value="">选择字段</option>${items.map(item => `<option value="${item.key}" ${cond.field === item.key ? 'selected' : ''}>${esc(item.name)}</option>`).join('')}</select>
${renderBranchConditionOperatorSelect(cond, fieldType, node.id, branch.id, gi, ci)}
${renderBranchConditionValueControl(cond, fieldType, field, node.id, branch.id, gi, ci)}
<button class="condition-remove" onclick="removeBranchCondition('${node.id}','${branch.id}',${gi},${ci})">&#10005;</button>
</div>`;
});
html += `<button class="btn-add-condition" onclick="addBranchCondition('${node.id}','${branch.id}',${gi})"><span>+</span> 添加条件</button></div>`;
if (gi < branch.conditionGroups.length - 1) html += '<div class="or-divider">或满足</div>';
});
html += `<button class="btn-add-condition" style="margin:10px 20px" onclick="addBranchConditionGroup('${node.id}','${branch.id}')"><span>+</span> 添加条件组</button>`;
return html;
}
/* ========== FLOW DESIGN ========== */
function renderFlowEditor() {
const editor = document.getElementById('flow-editor');
editor.innerHTML = '';
// Start node
editor.innerHTML += renderFlowStartNode();
// Connector
editor.innerHTML += renderFlowConnector('start', true);
// Nodes
flowNodes.forEach((node, idx) => {
editor.innerHTML += renderFlowNode(node);
if (idx < flowNodes.length) {
editor.innerHTML += renderFlowConnector(node.id, idx === flowNodes.length - 1);
}
});
// End node
editor.innerHTML += renderFlowEndNode();
}
function defaultNodeName(type) {
return { approver: '审批', cc: '抄送', handler: '办理', condition_branch: '条件分支', parallel_branch: '并行分支' }[type] || type;
}
function createFlowNode(type) {
const base = {
id: 'flow_' + (++flowIdCounter),
type,
nodeName: defaultNodeName(type),
approvers: [],
ccRecipients: [],
multiApprover: 'or',
formPermissions: {},
allowTransfer: true,
allowAddSign: true,
allowRemoveSign: true,
allowRollback: true,
emptyApproverAction: 'admin',
emptyApproverDesignate: '',
sameAsSubmitterAction: 'auto_skip',
emptyHandlerAction: 'admin',
emptyHandlerDesignate: '',
requireComment: false,
onlyCcOnAgree: false
};
if (type === 'approver') {
base.approvers = [createPersonEntry({ approverType: 'member' })];
} else if (type === 'handler') {
base.approvers = [createPersonEntry({ approverType: 'member' })];
base.allowTransfer = false;
base.allowAddSign = false;
base.allowRemoveSign = false;
base.allowRollback = false;
} else if (type === 'cc') {
base.approvers = [createPersonEntry({ approverType: 'superior' })];
}
return base;
}
function getFormFieldsByType(...types) {
const items = [];
function walk(fields) {
fields.forEach(f => {
if (types.includes(f.type)) items.push({ id: f.id, name: f.title });
if (f.detailChildren) walk(f.detailChildren);
});
}
walk(formFields);
return items;
}
function getAllFormFieldItems() {
const items = [];
formFields.forEach(f => {
if (f.type === 'date_range') {
items.push({ permKey: f.id + '_start', name: f.startTitle || '开始时间', fieldType: 'date_range' });
items.push({ permKey: f.id + '_end', name: f.endTitle || '结束时间', fieldType: 'date_range' });
items.push({ permKey: f.id + '_duration', name: f.durationTitle || '时长', fieldType: 'date_range' });
return;
}
if (f.type === 'detail') {
(f.detailChildren || []).forEach(c => items.push({ permKey: c.id, name: c.title, detailName: f.title, isDetailChild: true, fieldType: c.type }));
return;
}
items.push({ permKey: f.id, name: f.title, fieldType: f.type });
});
return items;
}
function getFormPermOptions(fieldType) {
return fieldType === 'desc_text' ? DESC_TEXT_PERM_OPTIONS : NORMAL_PERM_OPTIONS;
}
function normalizeFormPermValue(perm, fieldType) {
if (fieldType === 'desc_text' && perm === 'edit') return 'read';
return perm || 'read';
}
function renderFormPermRadios(contextKey, item, currentPerm, onchangePrefix) {
const perm = normalizeFormPermValue(currentPerm, item.fieldType);
const labels = { read: '可读', edit: '可编辑', hide: '隐藏' };
return getFormPermOptions(item.fieldType).map(p =>
`<label><input type="radio" name="perm_${contextKey}_${item.permKey}" value="${p}" ${perm === p ? 'checked' : ''} onchange="${onchangePrefix},'${p}')">${labels[p]}</label>`
).join('');
}
function getFormPerm(nodeOrPerms, permKey, fieldType) {
const perms = nodeOrPerms.formPermissions || nodeOrPerms;
let perm = 'read';
if (perms[permKey]) perm = perms[permKey];
else {
const legacyId = permKey.replace(/_(start|end|duration)$/, '');
if (legacyId !== permKey && perms[legacyId]) perm = perms[legacyId];
}
return normalizeFormPermValue(perm, fieldType);
}
function ensureNodeFormPermissions(node) {
if (!node.formPermissions) node.formPermissions = {};
getAllFormFieldItems().forEach(item => {
node.formPermissions[item.permKey] = getFormPerm(node, item.permKey, item.fieldType);
});
}
function renderFlowTabBar(tabs, active) {
return `<div class="approval-editor-tab-wrapper"><div class="ant-radio-group">${tabs.map(t =>
`<label class="ant-radio-button-wrapper ${active === t.id ? 'ant-radio-button-wrapper-checked' : ''}" onclick="switchFlowTab('${t.id}')"><span>${t.label}</span></label>`
).join('')}</div></div>`;
}
function switchFlowTab(tab) {
selectedFlowTab = tab;
renderFlowConfig();
}
function getFormFieldByName(name) {
if (!name) return null;
function walk(fields) {
for (const f of fields) {
if (f.title === name) return f;
if (f.detailChildren) {
const c = walk(f.detailChildren);
if (c) return c;
}
}
return null;
}
return walk(formFields);
}
function isDeptFieldMultiSelect(fieldName) {
const f = getFormFieldByName(fieldName);
return !!(f && f.deptSelectionMode === 'multiple');
}
function shouldDisableContactDeptHead(person) {
if (person.approverType !== 'form_contact') return false;
return getFormFieldsByType('department').some(f => f.deptSelectionMode === 'multiple');
}
function collectFlowNodesInOrder(nodes, list) {
if (!list) list = [];
(nodes || []).forEach(n => {
list.push(n);
if (n.branches) n.branches.forEach(b => collectFlowNodesInOrder(b.nodes, list));
});
return list;
}
function getPriorApproverNodes(nodeId) {
const ordered = collectFlowNodesInOrder(flowNodes);
const result = [];
for (const n of ordered) {
if (n.id === nodeId) break;
if (n.type === 'approver') result.push(n);
}
return result;
}
function getAllApproverNodes() {
return collectFlowNodesInOrder(flowNodes).filter(n => n.type === 'approver');
}
function getPersonTypeOptions(node, context) {
const base = ['superior', 'dept_head', 'role', 'user_group', 'member', 'self_select', 'self', 'form_contact', 'form_dept'];
if (context === 'nodeCc' || context === 'endpointCc' || node?.type === 'cc') return base.concat(['node_cc']);
if (node?.type === 'approver') return base.concat(['node_approver', 'multi_level_superior', 'multi_level_dept_head']);
return base;
}
function isCcPersonContext(context, nodeType) {
return context === 'nodeCc' || context === 'endpointCc' || nodeType === 'cc';
}
const sameAsSubmitterActionLabels = { self_approve: '由提交人对自己审批', auto_skip: '自动跳过', transfer_superior: '转交给直属上级审批', transfer_dept_head: '转交给部门负责人审批' };
function getContinuousLevelOptions(person) {
const isSuperior = person.approverType === 'multi_level_superior';
const baseUp = isSuperior ? '直属上级' : '直属部门负责人';
const baseTop = isSuperior ? '最高上级' : '最高部门负责人';
const mode = person.continuousLevelMode || 'bottom_up';
const opts = [];
for (let i = 0; i <= 19; i++) {
if (mode === 'bottom_up') {
opts.push({ value: i, label: i === 0 ? baseUp : baseUp + '加' + i + '级' });
} else {
opts.push({ value: i, label: i === 0 ? baseTop : baseTop + '减' + i + '级' });
}
}
return opts;
}
function getContinuousLevelToggleLabel(person) {
const isSuperior = person.approverType === 'multi_level_superior';
const mode = person.continuousLevelMode || 'bottom_up';
if (mode === 'bottom_up') {
return isSuperior ? '切换为最高上级向下' : '切换为最高部门负责人向下';
}
return isSuperior ? '切换为直属上级向上' : '切换为直属部门负责人向上';
}
function togglePersonContinuousLevelMode(nodeId, personId, context) {
const node = findFlowNode(flowNodes, nodeId);
if (!node) return;
let p;
if (context === 'nodeCc') p = node.ccRecipients?.find(x => x.id === personId);
else if (context === 'endpointCc') p = getCcRecipients(nodeId).find(x => x.id === personId);
else p = node.approvers?.find(x => x.id === personId);
if (!p) return;
p.continuousLevelMode = p.continuousLevelMode === 'top_down' ? 'bottom_up' : 'top_down';
p.continuousEndLevel = 0;
renderFlowConfig();
renderFlowEditor();
autoSave();
}
function renderMultiLevelContinuousPanel(nodeId, person, context) {
const pid = person.id;
const upd = (key, valExpr) => {
if (context === 'nodeCc') return `updateFlowNodeCc('${nodeId}','${pid}','${key}',${valExpr})`;
if (context === 'endpointCc') return `updateCcRecipient('${nodeId}','${pid}','${key}',${valExpr})`;
return `updateFlowNodePerson('${nodeId}','${pid}','${key}',${valExpr})`;
};
const opts = getContinuousLevelOptions(person);
const toggleLabel = getContinuousLevelToggleLabel(person);
const modeTip = (person.continuousLevelMode || 'bottom_up') === 'bottom_up'
? '从直属起向上依次审批至所选层级'
: '从最高起向下依次审批至所选层级';
return `<div class="item-wrap" style="padding:0;margin-top:8px">
<div class="sub-title bold" style="display:flex;align-items:center;justify-content:space-between;gap:8px">
<span>审批终点</span>
<button type="button" class="ud-btn ud-btn--text ud-btn--sm" style="padding:0;font-size:12px" onclick="togglePersonContinuousLevelMode('${nodeId}','${pid}','${context}')">${toggleLabel}</button>
</div>
<div class="level-select-wrapper"><div class="level-select-title">提交人的</div>
<select class="ant-select approver-select" onchange="${upd('continuousEndLevel', 'parseInt(this.value)')}">${opts.map(o => `<option value="${o.value}" ${parseInt(person.continuousEndLevel || 0) === o.value ? 'selected' : ''}>${o.label}</option>`).join('')}</select>
</div>
<div class="supervisor-select-tip">${modeTip}</div>
</div>`;
}
function renderFormDeptLevelSelect(nodeId, personId, level, context) {
let opts = `<option value="0" ${level === 0 || level === '0' ? 'selected' : ''}>直接负责人</option>`;
for (let i = 1; i <= 19; i++) {
opts += `<option value="${i}" ${parseInt(level) === i ? 'selected' : ''}>直接负责人加${i}级</option>`;
}
let fn;
if (context === 'nodeCc') fn = `updateFlowNodeCc('${nodeId}','${personId}','formDeptLevel',parseInt(this.value))`;
else if (context === 'endpointCc') fn = `updateCcRecipient('${nodeId}','${personId}','formDeptLevel',parseInt(this.value))`;
else fn = `updateFlowNodePerson('${nodeId}','${personId}','formDeptLevel',parseInt(this.value))`;
return `<div class="item-wrap" style="padding:0;margin-top:8px"><div class="sub-title bold">部门负责人层级</div><select class="ant-select" onchange="${fn}">${opts}</select></div>`;
}
function renderCcDeptLevelSelect(nodeId, personId, level, context) {
let opts = `<option value="0" ${level === 0 || level === '0' ? 'selected' : ''}>直属负责人</option>`;
for (let i = 1; i <= 19; i++) {
opts += `<option value="${i}" ${parseInt(level) === i ? 'selected' : ''}>直属负责人加${i}级</option>`;
}
let fn;
if (context === 'nodeCc') fn = `updateFlowNodeCc('${nodeId}','${personId}','formDeptLevel',parseInt(this.value))`;
else if (context === 'endpointCc') fn = `updateCcRecipient('${nodeId}','${personId}','formDeptLevel',parseInt(this.value))`;
else fn = `updateFlowNodePerson('${nodeId}','${personId}','formDeptLevel',parseInt(this.value))`;
return `<div class="item-wrap" style="padding:0;margin-top:8px;margin-left:16px"><select class="ant-select" onchange="${fn}">${opts}</select></div>`;
}
function renderFormContactRuleRadios(nodeId, person, context) {
const pid = person.id;
const isCc = isCcPersonContext(context);
const title = isCc ? '抄送类型' : '审批类型';
const rules = [
{ id: 'self', label: '联系人自己' },
{ id: 'contact_superior', label: '联系人上级' },
{ id: 'contact_dept_head', label: '联系人部门负责人', disabled: shouldDisableContactDeptHead(person) }
];
if (person.formContactRule === 'contact_dept_head' && shouldDisableContactDeptHead(person)) {
person.formContactRule = 'self';
}
const fn = (rid) => {
if (context === 'nodeCc') return `updateFlowNodeCc('${nodeId}','${pid}','formContactRule','${rid}');renderFlowConfig()`;
if (context === 'endpointCc') return `updateCcRecipient('${nodeId}','${pid}','formContactRule','${rid}');renderFlowConfig()`;
return `updateFlowNodePerson('${nodeId}','${pid}','formContactRule','${rid}');renderFlowConfig()`;
};
return `<div class="item-wrap" style="padding:0;margin-top:8px"><div class="sub-title bold">${title}</div><div class="approver-list">${rules.map(r =>
`<div class="radio-wrapper"><label class="ud-radio-wrapper ${person.formContactRule === r.id ? 'ud-radio-wrapper--checked' : ''}${r.disabled ? ' is-disabled' : ''}" ${r.disabled ? '' : `onclick="${fn(r.id)}"`}><span class="ud-radio"><span class="ud-radio__wallpaper"></span></span><span>${r.label}${r.disabled ? '(部门多选时不可用)' : ''}</span></label></div>`
).join('')}</div></div>`;
}
function renderCcFormDeptPicker(nodeId, person, context) {
const pid = person.id;
const fn = (mode) => {
if (context === 'nodeCc') return `updateFlowNodeCc('${nodeId}','${pid}','formDeptCcMode','${mode}');renderFlowConfig()`;
if (context === 'endpointCc') return `updateCcRecipient('${nodeId}','${pid}','formDeptCcMode','${mode}');renderFlowConfig()`;
return `updateFlowNodePerson('${nodeId}','${pid}','formDeptCcMode','${mode}');renderFlowConfig()`;
};
const mode = person.formDeptCcMode || 'dept_head';
let html = `<div class="item-wrap" style="padding:0;margin-top:8px"><div class="sub-title bold">选择抄送人</div><div class="approver-list">
<div class="radio-wrapper"><label class="ud-radio-wrapper ${mode === 'dept_head' ? 'ud-radio-wrapper--checked' : ''}" onclick="${fn('dept_head')}"><span class="ud-radio"><span class="ud-radio__wallpaper"></span></span><span>部门负责人</span></label></div>
<div class="radio-wrapper"><label class="ud-radio-wrapper ${mode === 'dept_member' ? 'ud-radio-wrapper--checked' : ''}" onclick="${fn('dept_member')}"><span class="ud-radio"><span class="ud-radio__wallpaper"></span></span><span>直属部门成员</span></label></div>
<div class="radio-wrapper"><label class="ud-radio-wrapper ${mode === 'dept_level' ? 'ud-radio-wrapper--checked' : ''}" onclick="${fn('dept_level')}"><span class="ud-radio"><span class="ud-radio__wallpaper"></span></span><span>所选部门的</span></label></div>
</div>`;
if (mode === 'dept_level') html += renderCcDeptLevelSelect(nodeId, pid, person.formDeptLevel, context);
html += `</div>`;
return html;
}
function renderPersonTypeRadios(nodeId, person, types, context, node) {
const all = [
{ id: 'superior', label: '上级' },
{ id: 'dept_head', label: '部门负责人' },
{ id: 'role', label: '角色' },
{ id: 'user_group', label: '用户组' },
{ id: 'member', label: '指定成员' },
{ id: 'self_select', label: '提交人自选' },
{ id: 'self', label: '提交人本人' },
{ id: 'form_contact', label: '表单内联系人' },
{ id: 'form_dept', label: '表单内部门' },
{ id: 'node_approver', label: '节点审批人' },
{ id: 'node_cc', label: '节点抄送人' },
{ id: 'multi_level_superior', label: '连续多级上级' },
{ id: 'multi_level_dept_head', label: '连续多级部门负责人' }
];
const fn = (tid) => {
if (context === 'nodeCc') return `updateFlowNodeCc('${nodeId}','${person.id}','approverType','${tid}');renderFlowConfig()`;
if (context === 'endpointCc') return `updateCcRecipient('${nodeId}','${person.id}','approverType','${tid}');renderFlowConfig()`;
return `updateFlowNodePerson('${nodeId}','${person.id}','approverType','${tid}');renderFlowConfig()`;
};
return all.filter(t => types.includes(t.id)).map(t =>
`<div class="radio-wrapper"><label class="ud-radio-wrapper ${person.approverType === t.id ? 'ud-radio-wrapper--checked' : ''}" onclick="${fn(t.id)}"><span class="ud-radio"><span class="ud-radio__wallpaper"></span></span><span>${t.label}</span></label></div>`
).join('');
}
function renderPersonTypeExtras(nodeId, person, context, node) {
const pid = person.id;
const nodeType = node?.type || 'approver';
const isCc = isCcPersonContext(context, nodeType);
const upd = (key, valExpr) => {
if (context === 'nodeCc') return `updateFlowNodeCc('${nodeId}','${pid}','${key}',${valExpr})`;
if (context === 'endpointCc') return `updateCcRecipient('${nodeId}','${pid}','${key}',${valExpr})`;
return `updateFlowNodePerson('${nodeId}','${pid}','${key}',${valExpr})`;
};
let html = '';
if (person.approverType === 'node_approver') {
const prior = getPriorApproverNodes(nodeId);
html += `<div class="item-wrap" style="padding:0;margin-top:8px"><div class="sub-title bold">选择审批节点</div><select class="ant-select" ${prior.length ? '' : 'disabled'} onchange="${upd('refApproverNodeId', 'this.value')}"><option value="">请选择节点</option>${prior.map(n => `<option value="${n.id}" ${person.refApproverNodeId === n.id ? 'selected' : ''}>${esc(n.nodeName || '审批')}</option>`).join('')}</select>${prior.length ? '' : '<div class="text-muted" style="font-size:12px;margin-top:6px">当前为第一个审批人节点,无法引用之前的审批节点</div>'}</div>`;
}
if (person.approverType === 'node_cc') {
const approvers = getAllApproverNodes();
html += `<div class="item-wrap" style="padding:0;margin-top:8px"><div class="sub-title bold">选择审批节点</div><select class="ant-select" ${approvers.length ? '' : 'disabled'} onchange="${upd('refApproverNodeId', 'this.value')}"><option value="">请选择审批节点</option>${approvers.map(n => `<option value="${n.id}" ${person.refApproverNodeId === n.id ? 'selected' : ''}>${esc(n.nodeName || '审批')}</option>`).join('')}</select>${approvers.length ? '' : '<div class="text-muted" style="font-size:12px;margin-top:6px">请先在流程中添加审批人节点</div>'}</div>`;
}
if (person.approverType === 'multi_level_superior' || person.approverType === 'multi_level_dept_head') {
html += renderMultiLevelContinuousPanel(nodeId, person, context);
}
if (['superior', 'dept_head'].includes(person.approverType)) {
html += `<div class="item-wrap" style="padding:0;margin-top:8px"><div class="sub-title bold">指定层级</div><div class="level-select-wrapper"><div class="level-select-title">提交人的</div><select class="ant-select approver-select" onchange="${upd('approverLevel', 'parseInt(this.value)')}"><option value="1" ${person.approverLevel === 1 ? 'selected' : ''}>直属${person.approverType === 'superior' ? '上级' : '部门负责人'}</option><option value="2" ${person.approverLevel === 2 ? 'selected' : ''}>第2级${person.approverType === 'superior' ? '上级' : '部门负责人'}</option><option value="3" ${person.approverLevel === 3 ? 'selected' : ''}>第3级${person.approverType === 'superior' ? '上级' : '部门负责人'}</option><option value="4" ${person.approverLevel === 4 ? 'selected' : ''}>第4级${person.approverType === 'superior' ? '上级' : '部门负责人'}</option></select></div><div class="supervisor-select-tip">为避免部分员工未设置上级导致流程错误,可前往飞书管理后台检查</div></div>`;
}
if (person.approverType === 'member') {
html += `<div class="item-wrap" style="padding:0;margin-top:8px"><div class="sub-title bold">指定成员</div><input type="text" class="ant-input" value="${esc(person.approverName || '')}" placeholder="输入成员姓名,多人用逗号分隔" oninput="${upd('approverName', 'this.value')};renderFlowEditor()"></div>`;
}
if (person.approverType === 'role') {
html += `<div class="item-wrap" style="padding:0;margin-top:8px"><div class="sub-title bold">选择角色</div><input type="text" class="ant-input" value="${esc(person.roleName || '')}" placeholder="输入角色名称" oninput="${upd('roleName', 'this.value')}"></div>`;
}
if (person.approverType === 'user_group') {
html += `<div class="item-wrap" style="padding:0;margin-top:8px"><div class="sub-title bold">选择用户组</div><input type="text" class="ant-input" value="${esc(person.userGroupName || '')}" placeholder="输入用户组名称" oninput="${upd('userGroupName', 'this.value')}"></div>`;
}
if (person.approverType === 'form_contact') {
html += `<div class="item-wrap" style="padding:0;margin-top:8px"><div class="sub-title bold">关联表单联系人字段</div><select class="ant-select" onchange="${upd('formContactField', 'this.value')};renderFlowConfig()"><option value="">请选择字段</option>${getFormFieldsByType('contact', 'member').map(f => `<option value="${esc(f.name)}" ${person.formContactField === f.name ? 'selected' : ''}>${esc(f.name)}</option>`).join('')}</select></div>`;
if (person.formContactField) html += renderFormContactRuleRadios(nodeId, person, context);
}
if (person.approverType === 'form_dept') {
html += `<div class="item-wrap" style="padding:0;margin-top:8px"><div class="sub-title bold">关联表单部门字段</div><select class="ant-select" onchange="${upd('formDeptField', 'this.value')};renderFlowConfig()"><option value="">请选择字段</option>${getFormFieldsByType('department').map(f => `<option value="${esc(f.name)}" ${person.formDeptField === f.name ? 'selected' : ''}>${esc(f.name)}</option>`).join('')}</select></div>`;
if (person.formDeptField) {
if (isCc) html += renderCcFormDeptPicker(nodeId, person, context);
else html += renderFormDeptLevelSelect(nodeId, pid, person.formDeptLevel, context);
}
}
return html;
}
function renderPersonCard(node, person, index, roleLabel, context) {
ensureNodeApprovers(node);
const list = context === 'nodeCc' ? node.ccRecipients : node.approvers;
const canDelete = list.length >= 1;
const delFn = context === 'nodeCc'
? `removeFlowNodeCc('${node.id}','${person.id}')`
: `removeFlowNodePerson('${node.id}','${person.id}')`;
const types = getPersonTypeOptions(node, context);
return `<div class="approver-wrapper">
<div class="header"><span>${roleLabel}${list.length > 1 ? ' ' + (index + 1) : ''}</span>${canDelete ? `<button type="button" class="delete-cc" title="删除" onclick="${delFn}">&#10005;</button>` : ''}</div>
<div class="main-content">
<div class="approver-list">${renderPersonTypeRadios(node.id, person, types, context, node)}</div>
${renderPersonTypeExtras(node.id, person, context, node)}
</div>
</div>`;
}
function renderNodePersonsPanel(node, roleLabel, addLabel) {
ensureNodeApprovers(node);
let html = '';
if (!node.approvers.length) {
html += `<div class="item-wrap"><div class="text-muted" style="font-size:12px">暂未添加${roleLabel}</div></div>`;
} else {
html += node.approvers.map((p, i) => renderPersonCard(node, p, i, roleLabel, 'nodePerson')).join('');
}
html += `<div class="item-wrap"><button type="button" class="ud-btn ud-btn--text ud-btn--sm" onclick="addFlowNodePerson('${node.id}')">${addLabel}</button></div>`;
return html;
}
function renderEmptyApproverPanel(node) {
return `<div class="item-wrap"><div class="sub-title bold">审批人为空时</div>
<div class="approver-list">
<div class="radio-wrapper"><label class="ud-radio-wrapper ${node.emptyApproverAction === 'auto_approve' ? 'ud-radio-wrapper--checked' : ''}" onclick="updateFlowNode('${node.id}','emptyApproverAction','auto_approve');renderFlowConfig()"><span class="ud-radio"><span class="ud-radio__wallpaper"></span></span><span>自动同意</span></label></div>
<div class="radio-wrapper"><label class="ud-radio-wrapper ${node.emptyApproverAction === 'designated' ? 'ud-radio-wrapper--checked' : ''}" onclick="updateFlowNode('${node.id}','emptyApproverAction','designated');renderFlowConfig()"><span class="ud-radio"><span class="ud-radio__wallpaper"></span></span><span>指定人员审批</span></label></div>
<div class="radio-wrapper"><label class="ud-radio-wrapper ${node.emptyApproverAction === 'admin' ? 'ud-radio-wrapper--checked' : ''}" onclick="updateFlowNode('${node.id}','emptyApproverAction','admin');renderFlowConfig()"><span class="ud-radio"><span class="ud-radio__wallpaper"></span></span><span>转交给审批管理员</span></label></div>
</div>
${node.emptyApproverAction === 'designated' ? `<div style="margin-top:8px"><input type="text" class="ant-input" value="${esc(node.emptyApproverDesignate || '')}" placeholder="输入审批人姓名" oninput="updateFlowNode('${node.id}','emptyApproverDesignate',this.value);renderFlowEditor()"></div>` : ''}
</div>`;
}
function renderSameAsSubmitterPanel(node) {
return `<div class="item-wrap"><div class="sub-title bold">审批人与提交人为同一人时</div>
<div class="approver-list">
<div class="radio-wrapper"><label class="ud-radio-wrapper ${node.sameAsSubmitterAction === 'self_approve' ? 'ud-radio-wrapper--checked' : ''}" onclick="updateFlowNode('${node.id}','sameAsSubmitterAction','self_approve');renderFlowConfig()"><span class="ud-radio"><span class="ud-radio__wallpaper"></span></span><span>由提交人对自己审批</span></label></div>
<div class="radio-wrapper"><label class="ud-radio-wrapper ${node.sameAsSubmitterAction === 'auto_skip' ? 'ud-radio-wrapper--checked' : ''}" onclick="updateFlowNode('${node.id}','sameAsSubmitterAction','auto_skip');renderFlowConfig()"><span class="ud-radio"><span class="ud-radio__wallpaper"></span></span><span>自动跳过</span></label></div>
<div class="radio-wrapper"><label class="ud-radio-wrapper ${node.sameAsSubmitterAction === 'transfer_superior' ? 'ud-radio-wrapper--checked' : ''}" onclick="updateFlowNode('${node.id}','sameAsSubmitterAction','transfer_superior');renderFlowConfig()"><span class="ud-radio"><span class="ud-radio__wallpaper"></span></span><span>转交给直属上级审批</span></label></div>
<div class="radio-wrapper"><label class="ud-radio-wrapper ${node.sameAsSubmitterAction === 'transfer_dept_head' ? 'ud-radio-wrapper--checked' : ''}" onclick="updateFlowNode('${node.id}','sameAsSubmitterAction','transfer_dept_head');renderFlowConfig()"><span class="ud-radio"><span class="ud-radio__wallpaper"></span></span><span>转交给部门负责人审批</span></label></div>
</div>
</div>`;
}
function renderEmptyHandlerPanel(node) {
return `<div class="item-wrap"><div class="sub-title bold">办理人为空时</div>
<div class="approver-list">
<div class="radio-wrapper"><label class="ud-radio-wrapper ${node.emptyHandlerAction === 'designated' ? 'ud-radio-wrapper--checked' : ''}" onclick="updateFlowNode('${node.id}','emptyHandlerAction','designated');renderFlowConfig()"><span class="ud-radio"><span class="ud-radio__wallpaper"></span></span><span>指定人员办理</span></label></div>
<div class="radio-wrapper"><label class="ud-radio-wrapper ${node.emptyHandlerAction === 'admin' ? 'ud-radio-wrapper--checked' : ''}" onclick="updateFlowNode('${node.id}','emptyHandlerAction','admin');renderFlowConfig()"><span class="ud-radio"><span class="ud-radio__wallpaper"></span></span><span>转交给审批管理员</span></label></div>
</div>
${node.emptyHandlerAction === 'designated' ? `<div style="margin-top:8px"><input type="text" class="ant-input" value="${esc(node.emptyHandlerDesignate || '')}" placeholder="输入办理人姓名" oninput="updateFlowNode('${node.id}','emptyHandlerDesignate',this.value);renderFlowEditor()"></div>` : ''}
</div>`;
}
function renderNodeCcPanel(node) {
ensureNodeApprovers(node);
if (!node.ccRecipients) node.ccRecipients = [];
let html = '';
if (!node.ccRecipients.length) {
html += `<div class="item-wrap"><div class="text-muted" style="font-size:12px">暂未设置抄送人</div></div>`;
} else {
html += node.ccRecipients.map((p, i) => renderPersonCard(node, p, i, '抄送人', 'nodeCc')).join('');
}
html += `<div class="item-wrap"><button type="button" class="ud-btn ud-btn--text ud-btn--sm" onclick="addFlowNodeCc('${node.id}')">+ 添加抄送人</button></div>`;
html += `<div class="item-wrap"><label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${node.onlyCcOnAgree ? 'checked' : ''} onchange="updateFlowNode('${node.id}','onlyCcOnAgree',this.checked)"><span class="form-other-item-label">仅同意时抄送</span></label></div>`;
html += `<div class="more-info-wrap"><p class="more-info-key">提示:</p><div class="more-info-content">抄送的人数最多支持100人以内</div></div>`;
return html;
}
function addFlowNodePerson(nodeId) {
const node = findFlowNode(flowNodes, nodeId);
if (!node) return;
ensureNodeApprovers(node);
if (node.approvers.length >= 100) return;
node.approvers.push(createPersonEntry({ approverType: node.type === 'cc' ? 'superior' : 'member' }));
renderFlowConfig();
renderFlowEditor();
autoSave();
}
function removeFlowNodePerson(nodeId, personId) {
const node = findFlowNode(flowNodes, nodeId);
if (!node || !node.approvers || !node.approvers.length) return;
node.approvers = node.approvers.filter(p => p.id !== personId);
renderFlowConfig();
renderFlowEditor();
autoSave();
}
function updateFlowNodePerson(nodeId, personId, key, val) {
const node = findFlowNode(flowNodes, nodeId);
if (!node) return;
const p = node.approvers?.find(x => x.id === personId);
if (!p) return;
p[key] = val;
if (key === 'approverType') renderFlowConfig();
renderFlowEditor();
autoSave();
}
function addFlowNodeCc(nodeId) {
const node = findFlowNode(flowNodes, nodeId);
if (!node) return;
ensureNodeApprovers(node);
if (!node.ccRecipients) node.ccRecipients = [];
if (node.ccRecipients.length >= 100) return;
node.ccRecipients.push(createPersonEntry({ id: 'cc_' + (++ccIdCounter), approverType: 'superior', formDeptCcMode: 'dept_head' }));
renderFlowConfig();
renderFlowEditor();
autoSave();
}
function removeFlowNodeCc(nodeId, ccId) {
const node = findFlowNode(flowNodes, nodeId);
if (!node || !node.ccRecipients || !node.ccRecipients.length) return;
node.ccRecipients = node.ccRecipients.filter(p => p.id !== ccId);
renderFlowConfig();
renderFlowEditor();
autoSave();
}
function updateFlowNodeCc(nodeId, ccId, key, val) {
const node = findFlowNode(flowNodes, nodeId);
if (!node) return;
const p = node.ccRecipients?.find(x => x.id === ccId);
if (!p) return;
p[key] = val;
if (key === 'approverType') renderFlowConfig();
renderFlowEditor();
autoSave();
}
function renderApproverTypeRadios(node, types) {
ensureNodeApprovers(node);
return renderPersonTypeRadios(node.id, node.approvers[0], types, 'nodePerson', node);
}
function renderApproverTypeExtras(node) {
ensureNodeApprovers(node);
return renderPersonTypeExtras(node.id, node.approvers[0], 'nodePerson', node);
}
function personDesc(r) {
if (r.approverType === 'node_approver' || r.approverType === 'node_cc') {
const refNode = findFlowNode(flowNodes, r.refApproverNodeId);
const label = r.approverType === 'node_cc' ? '节点抄送人' : '节点审批人';
return label + '·' + (refNode ? (refNode.nodeName || '审批') : '未选择');
}
if (r.approverType === 'multi_level_superior' || r.approverType === 'multi_level_dept_head') {
const opt = getContinuousLevelOptions(r).find(o => o.value === parseInt(r.continuousEndLevel || 0));
const prefix = r.approverType === 'multi_level_superior' ? '连续多级上级' : '连续多级部门负责人';
return prefix + '·' + (opt ? opt.label : '');
}
if (r.approverType === 'member' && r.approverName) return r.approverName;
if (r.approverType === 'role' && r.roleName) return r.roleName;
if (r.approverType === 'user_group' && r.userGroupName) return r.userGroupName;
if (r.approverType === 'form_contact') {
const field = r.formContactField ? '' + r.formContactField + '' : '';
const rule = formContactRuleLabels[r.formContactRule] || '';
return '表单内联系人' + field + (rule ? '·' + rule : '');
}
if (r.approverType === 'form_dept') {
const field = r.formDeptField ? '' + r.formDeptField + '' : '';
if (r.formDeptCcMode) {
if (r.formDeptCcMode === 'dept_head') return '表单内部门' + field + '·部门负责人';
if (r.formDeptCcMode === 'dept_member') return '表单内部门' + field + '·直属部门成员';
const lvl = parseInt(r.formDeptLevel) || 0;
const lvlText = lvl === 0 ? '直属负责人' : '直属负责人加' + lvl + '级';
return '表单内部门' + field + '·所选部门的' + lvlText;
}
const lvl = parseInt(r.formDeptLevel) || 0;
const lvlText = lvl === 0 ? '直接负责人' : '直接负责人加' + lvl + '级';
return '表单内部门' + field + '·' + lvlText;
}
const name = approverTypeLabels[r.approverType] || r.approverType;
if (['superior', 'dept_head'].includes(r.approverType) && r.approverLevel > 1) {
return name + '(+' + (r.approverLevel - 1) + '级)';
}
return name;
}
function ccRecipientDesc(r) {
return personDesc(r);
}
function formatCcListDesc(recipients) {
if (!recipients || !recipients.length) return '';
return recipients.map(ccRecipientDesc).join('');
}
function getCcRecipients(context) {
return context === 'start' ? startCcRecipients : endCcRecipients;
}
function renderCcTypeRadios(recipient, context) {
const fakeNode = { type: 'cc' };
return renderPersonTypeRadios(context, recipient, getPersonTypeOptions(fakeNode, 'endpointCc'), 'endpointCc', fakeNode);
}
function renderCcTypeExtras(recipient, context) {
return renderPersonTypeExtras(context, recipient, 'endpointCc', { type: 'cc' });
}
function renderCcRecipientCard(recipient, context, index) {
const list = getCcRecipients(context);
const canDelete = list.length > 1;
return `<div class="approver-wrapper">
<div class="header"><span>抄送人</span>${canDelete ? `<button type="button" class="delete-cc" title="删除" onclick="removeCcRecipient('${context}','${recipient.id}')">&#10005;</button>` : ''}</div>
<div class="main-content">
<div class="approver-list">${renderCcTypeRadios(recipient, context)}</div>
${renderCcTypeExtras(recipient, context)}
</div>
</div>`;
}
function renderCcRecipientsPanel(context) {
const recipients = getCcRecipients(context);
let html = recipients.map((r, i) => renderCcRecipientCard(r, context, i)).join('');
html += `<div class="item-wrap"><button type="button" class="ud-btn ud-btn--text ud-btn--sm" onclick="addCcRecipient('${context}')">+ 添加抄送人</button></div>`;
html += `<div class="more-info-wrap"><p class="more-info-key">提示:</p><div class="more-info-content">抄送的人数最多支持100人以内</div></div>`;
return html;
}
function addCcRecipient(context) {
const list = getCcRecipients(context);
if (list.length >= 100) return;
list.push(createCcRecipient());
renderFlowConfig();
renderFlowEditor();
autoSave();
}
function removeCcRecipient(context, id) {
const list = getCcRecipients(context);
if (list.length <= 1) return;
const idx = list.findIndex(r => r.id === id);
if (idx >= 0) list.splice(idx, 1);
renderFlowConfig();
renderFlowEditor();
autoSave();
}
function updateCcRecipient(context, id, key, val) {
const list = getCcRecipients(context);
const r = list.find(x => x.id === id);
if (!r) return;
r[key] = val;
if (r.formDeptLevel === undefined) r.formDeptLevel = 0;
if (!r.formContactRule) r.formContactRule = 'self';
if (!r.formDeptCcMode) r.formDeptCcMode = 'dept_head';
if (key === 'approverType' || key === 'formDeptField' || key === 'formContactField') renderFlowConfig();
renderFlowEditor();
autoSave();
}
function updateEndpointFormPerm(context, fieldId, perm) {
const perms = context === 'start' ? startFormPermissions : endFormPermissions;
const item = getAllFormFieldItems().find(i => i.permKey === fieldId);
perms[fieldId] = normalizeFormPermValue(perm, item?.fieldType);
autoSave();
}
function renderEndpointFormPermPanel(context) {
const perms = context === 'start' ? startFormPermissions : endFormPermissions;
const items = getAllFormFieldItems();
if (!items.length) return '<div class="config-panel-hint">请先在表单设计中添加字段</div>';
let rows = '';
let lastDetail = null;
items.forEach(item => {
if (item.isDetailChild && item.detailName !== lastDetail) {
rows += `<tr class="form-perm-detail-header"><td colspan="2">${esc(item.detailName)}(明细/表格)</td></tr>`;
lastDetail = item.detailName;
}
const perm = getFormPerm(perms, item.permKey, item.fieldType);
rows += `<tr class="${item.isDetailChild ? 'form-perm-detail-child' : ''}"><td>${esc(item.name)}</td><td><div class="form-perm-radio">${renderFormPermRadios(context, item, perm, `updateEndpointFormPerm('${context}','${item.permKey}'`)}</div></td></tr>`;
});
return `<table class="form-perm-table"><thead><tr><th>字段名称</th><th>权限</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function renderFormPermPanel(node) {
ensureNodeFormPermissions(node);
const items = getAllFormFieldItems();
if (!items.length) return '<div class="config-panel-hint">请先在表单设计中添加字段</div>';
let rows = '';
let lastDetail = null;
items.forEach(item => {
if (item.isDetailChild && item.detailName !== lastDetail) {
rows += `<tr class="form-perm-detail-header"><td colspan="2">${esc(item.detailName)}(明细/表格)</td></tr>`;
lastDetail = item.detailName;
}
const perm = getFormPerm(node, item.permKey, item.fieldType);
rows += `<tr class="${item.isDetailChild ? 'form-perm-detail-child' : ''}"><td>${esc(item.name)}</td><td><div class="form-perm-radio">${renderFormPermRadios(node.id, item, perm, `updateNodeFormPerm('${node.id}','${item.permKey}'`)}</div></td></tr>`;
});
return `<table class="form-perm-table"><thead><tr><th>字段名称</th><th>权限</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function updateNodeFormPerm(nodeId, permKey, perm) {
const node = findFlowNode(flowNodes, nodeId);
if (!node) return;
const item = getAllFormFieldItems().find(i => i.permKey === permKey);
if (!node.formPermissions) node.formPermissions = {};
node.formPermissions[permKey] = normalizeFormPermValue(perm, item?.fieldType);
autoSave();
}
function renderOpPermPanel(node) {
return `<div class="item-wrap"><div class="sub-title bold">操作权限</div>
<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${node.allowTransfer ? 'checked' : ''} onchange="updateFlowNode('${node.id}','allowTransfer',this.checked)"><span class="form-other-item-label">允许转交</span></label>
<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${node.allowAddSign ? 'checked' : ''} onchange="updateFlowNode('${node.id}','allowAddSign',this.checked)"><span class="form-other-item-label">允许加签</span></label>
<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${node.allowRemoveSign ? 'checked' : ''} onchange="updateFlowNode('${node.id}','allowRemoveSign',this.checked)"><span class="form-other-item-label">允许减签</span></label>
<label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${node.allowRollback ? 'checked' : ''} onchange="updateFlowNode('${node.id}','allowRollback',this.checked)"><span class="form-other-item-label">允许回退</span></label>
</div>
<div class="item-wrap"><label class="form-other-item"><input type="checkbox" class="form-other-item-checkbox" ${node.requireComment ? 'checked' : ''} onchange="updateFlowNode('${node.id}','requireComment',this.checked)"><span class="form-other-item-label">审批人必须填写审批意见</span></label></div>`;
}
function renderFlowNodeNameHeader(node) {
return `<div class="approval-editor-name-wrapper"><input class="node-name-input" value="${esc(node.nodeName || defaultNodeName(node.type))}" placeholder="节点名称" oninput="updateFlowNode('${node.id}','nodeName',this.value);renderFlowEditor()"></div>`;
}
const FLOW_NODE_EDIT_ICON = '<span class="node-name-edit-hint" title="可编辑节点名称"><svg viewBox="0 0 24 24" fill="none"><path d="M4 20h4l10.5-10.5a1.5 1.5 0 0 0-4.24-4.24L4 15.76V20Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M13.5 6.5l4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>';
function renderFlowNodeTitleEditable(node) {
if (!['approver', 'cc', 'handler'].includes(node.type)) {
return `<div class="node-title-name">${esc(node.nodeName || defaultNodeName(node.type))}</div>`;
}
const val = node.nodeName || defaultNodeName(node.type);
return `<div class="node-title-name-wrap">${FLOW_NODE_EDIT_ICON}<input type="text" class="flow-node-name-input" value="${esc(val)}" placeholder="节点名称" onclick="event.stopPropagation()" onmousedown="event.stopPropagation()" onfocus="event.stopPropagation()" oninput="updateFlowNode('${node.id}','nodeName',this.value);renderFlowEditor()"></div>`;
}
function renderFlowStartNode() {
const { type: submitterType, value: submitterValue } = getSubmitterConfig();
const submitterText = submitterType === 'all' ? '全员' : (submitterValue || submitterTypeLabels[submitterType] || '指定人员');
const startCcDesc = formatCcListDesc(startCcRecipients);
const active = selectedFlowTarget === 'start' ? 'active' : '';
return `
<div class="flow-editor-node start ${active}">
<div class="flow-editor-node-container" onclick="selectFlowTarget('start')">
<div class="node-title"><div class="node-title-name">提交</div></div>
<div class="node-content">
<div class="node-detail">
<div class="node-detail-item">提交人:${esc(submitterText)}</div>
${startCcDesc ? `<div class="node-detail-item">抄送人:${esc(startCcDesc)}</div>` : '<div class="node-detail-item" style="color:var(--text-caption)">可设置抄送人</div>'}
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M9.293 17.293a1 1 0 0 0 1.414 1.414l6-6a1 1 0 0 0 0-1.414l-6-6a1 1 0 0 0-1.414 1.414L14.586 12l-5.293 5.293Z" fill="currentColor"/></svg>
</div>
</div>
</div>`;
}
function renderFlowEndNode() {
const endCcDesc = formatCcListDesc(endCcRecipients);
const active = selectedFlowTarget === 'end' ? 'active' : '';
return `
<div class="flow-editor-node end ${active}">
<div class="flow-editor-node-container" onclick="selectFlowTarget('end')">
<div class="node-title"><div class="node-title-name">结束</div></div>
<div class="node-content">
<div class="node-detail"><div class="node-detail-item">${endCcDesc ? '抄送人:' + esc(endCcDesc) : '流程结束'}</div></div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M9.293 17.293a1 1 0 0 0 1.414 1.414l6-6a1 1 0 0 0 0-1.414l-6-6a1 1 0 0 0-1.414 1.414L14.586 12l-5.293 5.293Z" fill="currentColor"/></svg>
</div>
</div>
</div>`;
}
function renderFlowConnector(prevId, isLast) {
const menuId = 'menu_' + (prevId === 'start' ? 'after_start' : 'after_' + prevId);
return `
<div class="bottom-v-container">
<div class="flow-v-track"></div>
${FLOW_ARROW_DOWN}
<div class="add-node-btn">
<div class="add-btn" onclick="showAddMenu('${menuId}')">+</div>
<div class="add-menu" id="${menuId}">
<div class="add-menu-item" onclick="insertFlowNode('${prevId}','approver')"><span style="color:var(--primary)">&#128100;</span> 审批人</div>
<div class="add-menu-item" onclick="insertFlowNode('${prevId}','cc')"><span style="color:var(--success)">&#9993;</span> 抄送人</div>
<div class="add-menu-item" onclick="insertFlowNode('${prevId}','handler')"><span style="color:var(--warning)">&#128221;</span> 办理人</div>
<div class="add-menu-item" onclick="insertFlowNode('${prevId}','condition_branch')"><span style="color:#8b5cf6">&#8644;</span> 条件分支</div>
<div class="add-menu-item" onclick="insertFlowNode('${prevId}','parallel_branch')"><span style="color:#0ea5e9">&#8801;</span> 并行分支</div>
</div>
</div>
</div>`;
}
function renderFlowNode(node) {
const typeNames = { approver: '审批', cc: '抄送', handler: '办理', condition_branch: '条件分支', parallel_branch: '并行分支' };
const typeClass = { approver: 'approval', cc: 'cc', handler: 'handler', condition_branch: 'branch', parallel_branch: 'parallel' }[node.type] || '';
const isBranch = node.type === 'condition_branch' || node.type === 'parallel_branch';
if (isBranch) {
const addBranchLabel = node.type === 'condition_branch' ? '添加条件分支' : '添加并行分支';
let branchesHtml = '';
node.branches.forEach((b, bi) => {
const isElse = b.isElse || bi === node.branches.length - 1;
const branchActive = selectedFlowBranchId === node.id + '::' + b.id;
const labelClass = isElse ? 'flow-branch-else' : 'flow-branch-label';
branchesHtml += `
<div class="flow-branch">
<div class="flow-branch-rail-top"></div>
<div class="flow-branch-stem">${FLOW_ARROW_DOWN}</div>
<div class="${labelClass}${branchActive ? ' active' : ''}" onclick="event.stopPropagation();selectFlowBranch('${node.id}','${b.id}')">${esc(b.label)}</div>
<div class="flow-branch-content">
${b.nodes.map(n => renderFlowNode(n)).join('')}
${b.nodes.length === 0 ? renderFlowConnector(b.id, true) : renderFlowConnector(b.nodes[b.nodes.length - 1].id, true)}
</div>
<div class="flow-branch-tail"></div>
<div class="flow-branch-rail-bottom"></div>
</div>`;
});
return `
<div class="flow-route-wrap ${node.type === 'parallel_branch' ? 'parallel-route' : 'condition-route'} ${node.id === selectedFlowNodeId ? 'active' : ''}" data-id="${node.id}">
<div class="add-branch" onclick="event.stopPropagation();addFlowBranch('${node.id}')">
<div class="add-branch-inner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 2a1 1 0 0 0-1 1v8H3a1 1 0 1 0 0 2h8v8a1 1 0 1 0 2 0v-8h8a1 1 0 1 0 0-2h-8V3a1 1 0 0 0-1-1Z" fill="currentColor"/></svg>
${addBranchLabel}
</div>
</div>
<div class="flow-split-from-add">${FLOW_ARROW_DOWN}</div>
<div class="flow-branch-container" style="--branch-count:${node.branches.length}">${branchesHtml}</div>
<div class="flow-merge-zone">${FLOW_ARROW_DOWN}</div>
</div>`;
}
const desc = approverDesc(node);
const ccExtra = node.type === 'approver' && node.ccRecipients && node.ccRecipients.length
? `<div class="node-detail-item">抄送:${esc(formatCcListDesc(node.ccRecipients))}${node.onlyCcOnAgree ? '(仅同意时)' : ''}</div>` : '';
const displayName = node.nodeName || defaultNodeName(node.type);
const isActive = node.id === selectedFlowNodeId;
return `
<div class="flow-editor-node ${typeClass} ${isActive ? 'active' : ''}" data-id="${node.id}">
<div class="flow-editor-node-container" onclick="selectFlowNode('${node.id}')">
<div class="node-title">
${renderFlowNodeTitleEditable(node)}
</div>
<div class="node-content">
<div class="node-detail"><div class="node-detail-item">${esc(desc)}</div>${ccExtra}</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M9.293 17.293a1 1 0 0 0 1.414 1.414l6-6a1 1 0 0 0 0-1.414l-6-6a1 1 0 0 0-1.414 1.414L14.586 12l-5.293 5.293Z" fill="currentColor"/></svg>
</div>
<div class="delete-btn" onclick="event.stopPropagation();deleteFlowNode('${node.id}')">&#10005;</div>
</div>
</div>`;
}
function approverDesc(node) {
ensureNodeApprovers(node);
const prefix = node.type === 'cc' ? '抄送人' : (node.type === 'handler' ? '办理人' : '审批人');
const list = node.approvers.map(p => personDesc(p));
if (!list.length) {
if (node.type === 'approver') {
if (node.emptyApproverAction === 'auto_approve') return prefix + ':为空时自动同意';
if (node.emptyApproverAction === 'designated') return prefix + ':为空时指定' + (node.emptyApproverDesignate || '人员');
return prefix + ':为空时转交管理员';
}
if (node.type === 'handler') {
if (node.emptyHandlerAction === 'designated') return prefix + ':为空时指定' + (node.emptyHandlerDesignate || '人员');
return prefix + ':为空时转交管理员';
}
return prefix + ':未设置';
}
return prefix + '' + list.join('');
}
function showAddMenu(menuId) {
document.querySelectorAll('.add-menu').forEach(m => m.classList.remove('show'));
const menu = document.getElementById(menuId);
if (menu) menu.classList.add('show');
}
document.addEventListener('click', e => {
if (!e.target.closest('.add-menu') && !e.target.closest('.add-btn')) {
document.querySelectorAll('.add-menu').forEach(m => m.classList.remove('show'));
}
});
function insertFlowNode(prevId, type) {
showAddMenu('');
let node;
if (type === 'condition_branch' || type === 'parallel_branch') node = createBranchFlowNode(type);
else node = createFlowNode(type);
if (!insertNodeAfterPrev(prevId, node)) flowNodes.push(node);
renderFlowEditor();
selectFlowNode(node.id);
autoSave();
}
function addFlowNode(type) {
let node;
if (type === 'condition_branch' || type === 'parallel_branch') node = createBranchFlowNode(type);
else node = createFlowNode(type);
flowNodes.push(node);
renderFlowEditor();
selectFlowNode(node.id);
autoSave();
}
function deleteFlowNode(id) {
function remove(nodes) {
const idx = nodes.findIndex(n => n.id === id);
if (idx >= 0) { nodes.splice(idx, 1); return true; }
for (const n of nodes) { if (n.branches) { for (const b of n.branches) { if (remove(b.nodes)) return true; } } }
return false;
}
remove(flowNodes);
if (selectedFlowNodeId === id) { selectedFlowNodeId = null; selectedFlowTarget = null; selectedFlowBranchId = null; renderFlowConfig(); }
renderFlowEditor();
autoSave();
}
function selectFlowTarget(target) {
selectedFlowTarget = target;
selectedFlowNodeId = null;
selectedFlowBranchId = null;
selectedFlowTab = 'basic';
renderFlowEditor();
renderFlowConfig();
}
function selectFlowNode(id) {
selectedFlowNodeId = id;
selectedFlowTarget = null;
selectedFlowBranchId = null;
selectedFlowTab = 'basic';
renderFlowEditor();
renderFlowConfig();
}
function selectFlowBranch(nodeId, branchId) {
selectedFlowNodeId = nodeId;
selectedFlowBranchId = nodeId + '::' + branchId;
selectedFlowTarget = null;
selectedFlowTab = 'basic';
renderFlowEditor();
renderFlowConfig();
}
function findFlowNode(nodes, id) {
for (const n of nodes) { if (n.id === id) return n; if (n.branches) { for (const b of n.branches) { const f = findFlowNode(b.nodes, id); if (f) return f; } } }
return null;
}
function renderFlowConfig() {
const panel = document.getElementById('flow-config-panel');
if (selectedFlowTarget === 'start') {
panel.innerHTML = renderStartNodeConfig();
return;
}
if (selectedFlowTarget === 'end') {
panel.innerHTML = renderEndNodeConfig();
return;
}
const branchCtx = getSelectedBranchContext();
if (branchCtx) {
panel.innerHTML = renderBranchItemConfig(branchCtx.node, branchCtx.branch);
return;
}
const node = findFlowNode(flowNodes, selectedFlowNodeId);
if (!node) {
panel.innerHTML = '<div class="config-panel-empty"></div>';
return;
}
if (node.type === 'condition_branch' || node.type === 'parallel_branch') {
panel.innerHTML = renderBranchConfig(node);
} else if (node.type === 'approver') {
panel.innerHTML = renderApproverConfig(node);
} else if (node.type === 'cc') {
panel.innerHTML = renderCcConfig(node);
} else if (node.type === 'handler') {
panel.innerHTML = renderHandlerConfig(node);
}
}
function renderStartNodeConfig() {
const { type: submitterType, value: submitterValue } = getSubmitterConfig();
const tabs = [{ id: 'basic', label: '基础设置' }, { id: 'formPerm', label: '表单权限' }];
let body = '';
if (selectedFlowTab === 'basic') {
body = `<div class="approval-editor-form">
<div class="item-wrap"><div class="item-key">谁可以提交该审批</div>
<select class="ant-select" onchange="document.getElementById('submitter-type').value=this.value;updateSubmitterType(this.value)">
<option value="all" ${submitterType === 'all' ? 'selected' : ''}>全员</option>
<option value="dept" ${submitterType === 'dept' ? 'selected' : ''}>指定部门</option>
<option value="member" ${submitterType === 'member' ? 'selected' : ''}>指定成员</option>
</select>
<div class="text-muted" style="font-size:12px;margin-top:6px">与「基础信息」中设置保持同步</div>
</div>
${submitterType !== 'all' ? `<div class="item-wrap"><div class="sub-title bold">指定范围</div><input type="text" class="ant-input" value="${esc(submitterValue)}" placeholder="输入部门或成员名称" oninput="document.getElementById('submitter-value').value=this.value;updateSubmitterValueLive(this.value)"></div>` : ''}
${renderCcRecipientsPanel('start')}
</div>`;
} else {
body = `<div class="approval-editor-form">${renderEndpointFormPermPanel('start')}</div>`;
}
return `<div class="approval-editor-name-wrapper"><div class="approval-editor-name">提交</div></div>${renderFlowTabBar(tabs, selectedFlowTab)}${body}<div class="btn-group"><button class="ud-btn ud-btn--filled" onclick="saveFlowNode()">保存</button><button class="ud-btn ud-btn--outlined" onclick="cancelFlowEdit()">取消</button></div>`;
}
function renderEndNodeConfig() {
const tabs = [{ id: 'basic', label: '设置抄送人' }, { id: 'formPerm', label: '表单权限' }];
let body = '';
if (selectedFlowTab === 'basic') {
body = `<div class="approval-editor-form">${renderCcRecipientsPanel('end')}</div>`;
} else {
body = `<div class="approval-editor-form">${renderEndpointFormPermPanel('end')}</div>`;
}
return `<div class="approval-editor-name-wrapper"><div class="approval-editor-name">结束</div></div>${renderFlowTabBar(tabs, selectedFlowTab)}${body}<div class="btn-group"><button class="ud-btn ud-btn--filled" onclick="saveFlowNode()">保存</button><button class="ud-btn ud-btn--outlined" onclick="cancelFlowEdit()">取消</button></div>`;
}
function renderApproverConfig(node) {
ensureNodeApprovers(node);
const tabs = [{ id: 'basic', label: '基础设置' }, { id: 'formPerm', label: '表单权限' }, { id: 'opPerm', label: '操作权限' }];
let body = '';
if (selectedFlowTab === 'basic') {
body = `<div class="approval-editor-form">
${renderNodePersonsPanel(node, '审批人', '+ 添加审批人')}
${renderEmptyApproverPanel(node)}
${renderSameAsSubmitterPanel(node)}
<div class="item-wrap"><div class="sub-title bold">多人审批方式</div>
<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>
${renderNodeCcPanel(node)}
</div>`;
} else if (selectedFlowTab === 'formPerm') {
body = `<div class="approval-editor-form">${renderFormPermPanel(node)}</div>`;
} else {
body = `<div class="approval-editor-form">${renderOpPermPanel(node)}</div>`;
}
return `${renderFlowNodeNameHeader(node)}${renderFlowTabBar(tabs, selectedFlowTab)}${body}<div class="btn-group"><button class="ud-btn ud-btn--filled" onclick="saveFlowNode()">保存</button><button class="ud-btn ud-btn--outlined" onclick="cancelFlowEdit()">取消</button></div>`;
}
function renderCcConfig(node) {
ensureNodeApprovers(node);
const tabs = [{ id: 'basic', label: '抄送人' }, { id: 'formPerm', label: '表单权限' }];
let body = '';
if (selectedFlowTab === 'basic') {
body = `<div class="approval-editor-form">
${renderNodePersonsPanel(node, '抄送人', '+ 添加抄送人')}
<div class="more-info-wrap"><p class="more-info-key">提示:</p><div class="more-info-content">抄送的人数最多支持100人以内</div></div>
</div>`;
} else {
body = `<div class="approval-editor-form">${renderFormPermPanel(node)}</div>`;
}
return `${renderFlowNodeNameHeader(node)}${renderFlowTabBar(tabs, selectedFlowTab)}${body}<div class="btn-group"><button class="ud-btn ud-btn--filled" onclick="saveFlowNode()">保存</button><button class="ud-btn ud-btn--outlined" onclick="cancelFlowEdit()">取消</button></div>`;
}
function renderHandlerConfig(node) {
ensureNodeApprovers(node);
const tabs = [{ id: 'basic', label: '设置办理人' }, { id: 'formPerm', label: '表单权限' }, { id: 'opPerm', label: '操作权限' }];
let body = '';
if (selectedFlowTab === 'basic') {
body = `<div class="approval-editor-form">
${renderNodePersonsPanel(node, '办理人', '+ 添加办理人')}
${renderEmptyHandlerPanel(node)}
<div class="more-info-wrap"><p class="more-info-key">提示:</p><div class="more-info-content"><p>办理人不涉及审批人去重设置,不同节点相同的办理人仍需要执行。</p><p>若办理人离职,会自动转交给办理人的上级代为处理。</p></div></div>
</div>`;
} else if (selectedFlowTab === 'formPerm') {
body = `<div class="approval-editor-form">${renderFormPermPanel(node)}</div>`;
} else {
body = `<div class="approval-editor-form"><div class="item-wrap"><div class="sub-title bold">操作权限</div><div class="text-muted" style="font-size:12px">办理节点默认允许提交办理结果</div></div></div>`;
}
return `${renderFlowNodeNameHeader(node)}${renderFlowTabBar(tabs, selectedFlowTab)}${body}<div class="btn-group"><button class="ud-btn ud-btn--filled" onclick="saveFlowNode()">保存</button><button class="ud-btn ud-btn--outlined" onclick="cancelFlowEdit()">取消</button></div>`;
}
function renderBranchItemConfig(node, branch) {
const isCondition = node.type === 'condition_branch';
const isElse = branch.isElse;
let html = `
<div class="approval-editor-name-wrapper"><div class="approval-editor-name">${esc(isCondition ? '条件分支' : '并行分支')} · ${esc(branch.label)}</div></div>
<div class="condition-header-wrap">
<h3 class="condition-name-box"><div class="condition-name">${isElse ? '其他情况' : '分支条件设置'}</div></h3>
</div>
<div class="approval-editor-form">`;
if (!isElse) {
html += `<div class="item-wrap"><div class="item-key">分支名称</div><input type="text" class="ant-input" value="${esc(branch.label)}" onchange="updateBranchLabel('${node.id}','${branch.id}',this.value)"></div>`;
}
html += renderBranchConditionGroups(node, branch);
html += `</div>
<div class="btn-group">
<button class="ud-btn ud-btn--outlined ud-btn--sm" style="margin-right:auto;color:var(--danger);border-color:var(--danger)" onclick="deleteFlowNode('${node.id}')">删除${isCondition ? '条件' : '并行'}分支</button>
<button class="ud-btn ud-btn--filled" onclick="saveFlowNode()">保存</button>
<button class="ud-btn ud-btn--outlined" onclick="cancelFlowEdit()">取消</button>
</div>`;
return html;
}
function renderBranchConfig(node) {
const branchCtx = getSelectedBranchContext();
if (branchCtx && branchCtx.node.id === node.id) return renderBranchItemConfig(branchCtx.node, branchCtx.branch);
const isCondition = node.type === 'condition_branch';
return `
${renderFlowNodeNameHeader(node)}
<div class="condition-header-wrap"><h3 class="condition-name-box"><div class="condition-name">${isCondition ? '条件分支' : '并行分支'}</div></h3></div>
<div class="approval-editor-form"><div class="item-wrap text-muted" style="font-size:13px">点击画布中的分支标签(如「${isCondition ? '条件分支 1' : '并行分支1'}」)设置条件;点击「${isCondition ? '添加条件分支' : '添加并行分支'}」增加分支。</div></div>
<div class="btn-group">
<button class="ud-btn ud-btn--outlined ud-btn--sm" style="margin-right:auto;color:var(--danger);border-color:var(--danger)" onclick="deleteFlowNode('${node.id}')">删除${isCondition ? '条件' : '并行'}分支</button>
<button class="ud-btn ud-btn--filled" onclick="saveFlowNode()">保存</button>
<button class="ud-btn ud-btn--outlined" onclick="cancelFlowEdit()">取消</button>
</div>`;
}
function updateFlowNode(id, key, val) {
const node = findFlowNode(flowNodes, id);
if (node) { node[key] = val; renderFlowEditor(); autoSave(); }
}
function saveFlowNode() { autoSave(); }
function cancelFlowEdit() {
selectedFlowNodeId = null;
selectedFlowTarget = null;
selectedFlowBranchId = null;
selectedFlowTab = 'basic';
renderFlowEditor();
renderFlowConfig();
}
function updateBranchLabel(nodeId, branchId, val) {
const node = findFlowNode(flowNodes, nodeId);
if (node) {
const b = node.branches.find(x => x.id === branchId);
if (b && !b.isElse) { b.label = val; renderFlowEditor(); renderFlowConfig(); autoSave(); }
}
}
function addFlowBranch(nodeId) {
const node = findFlowNode(flowNodes, nodeId);
if (!node || !node.branches) return;
const nonElseCount = node.branches.filter(b => !b.isElse).length;
if (node.type === 'condition_branch') {
if (nonElseCount >= 4) return;
const newB = createDefaultBranch(false, '条件分支 ' + (nonElseCount + 1), true);
node.branches.splice(node.branches.length - 1, 0, newB);
} else if (node.type === 'parallel_branch') {
if (nonElseCount >= 9) return;
const newB = createDefaultBranch(false, '并行分支' + (nonElseCount + 1), false);
node.branches.splice(node.branches.length - 1, 0, newB);
}
renderFlowConfig();
renderFlowEditor();
autoSave();
}
function getBranchRef(nodeId, branchId) {
const node = findFlowNode(flowNodes, nodeId);
const branch = node?.branches?.find(b => b.id === branchId);
return node && branch ? { node, branch } : null;
}
function addBranchConditionGroup(nodeId, branchId) {
const ref = getBranchRef(nodeId, branchId);
if (!ref || ref.branch.isElse) return;
if (!ref.branch.conditionGroups) ref.branch.conditionGroups = [];
ref.branch.conditionGroups.push([createEmptyBranchCondition()]);
renderFlowConfig();
autoSave();
}
function addBranchCondition(nodeId, branchId, gi) {
const ref = getBranchRef(nodeId, branchId);
if (!ref?.branch.conditionGroups?.[gi]) return;
ref.branch.conditionGroups[gi].push(createEmptyBranchCondition());
renderFlowConfig();
autoSave();
}
function removeBranchConditionGroup(nodeId, branchId, gi) {
const ref = getBranchRef(nodeId, branchId);
if (!ref?.branch.conditionGroups) return;
ref.branch.conditionGroups.splice(gi, 1);
renderFlowConfig();
autoSave();
}
function removeBranchCondition(nodeId, branchId, gi, ci) {
const ref = getBranchRef(nodeId, branchId);
const group = ref?.branch.conditionGroups?.[gi];
if (!group) return;
group.splice(ci, 1);
if (!group.length) ref.branch.conditionGroups.splice(gi, 1);
renderFlowConfig();
autoSave();
}
function updateBranchCondition(nodeId, branchId, gi, ci, key, val) {
const ref = getBranchRef(nodeId, branchId);
const cond = ref?.branch.conditionGroups?.[gi]?.[ci];
if (!cond) return;
cond[key] = val;
if (key === 'field') {
normalizeBranchCondition(cond);
cond.value = '';
cond.values = [];
cond.currency = '';
cond.address = { country: '中国', province: '', city: '', district: '' };
renderFlowConfig();
} else if (key === 'operator') {
renderFlowConfig();
}
autoSave();
}
function updateBranchConditionSingleChoice(nodeId, branchId, gi, ci, optionIndex) {
const ref = getBranchRef(nodeId, branchId);
const cond = ref?.branch.conditionGroups?.[gi]?.[ci];
if (!cond) return;
const { field } = resolveBranchConditionField(cond.field);
const idx = parseInt(optionIndex, 10);
const opt = Number.isNaN(idx) || idx < 0 ? null : field?.options?.[idx];
cond.values = opt ? [opt] : [];
autoSave();
}
function toggleBranchConditionChoice(nodeId, branchId, gi, ci, optionIndex, checked) {
const ref = getBranchRef(nodeId, branchId);
const cond = ref?.branch.conditionGroups?.[gi]?.[ci];
if (!cond) return;
const { field } = resolveBranchConditionField(cond.field);
const opt = field?.options?.[optionIndex];
if (!opt) return;
if (!Array.isArray(cond.values)) cond.values = [];
if (checked && !cond.values.includes(opt)) cond.values.push(opt);
else if (!checked) cond.values = cond.values.filter(v => v !== opt);
autoSave();
}
function updateBranchConditionDeptValues(nodeId, branchId, gi, ci, text) {
const ref = getBranchRef(nodeId, branchId);
const cond = ref?.branch.conditionGroups?.[gi]?.[ci];
if (!cond) return;
cond.values = String(text || '').split(/[,]/).map(s => s.trim()).filter(Boolean);
autoSave();
}
function updateBranchConditionAddress(nodeId, branchId, gi, ci, key, val) {
const ref = getBranchRef(nodeId, branchId);
const cond = ref?.branch.conditionGroups?.[gi]?.[ci];
if (!cond) return;
if (!cond.address) cond.address = { country: '中国', province: '', city: '', district: '' };
if (key === 'country') {
cond.address.country = val;
cond.address.province = '';
cond.address.city = '';
cond.address.district = '';
applyCountryAddressDefaults(val, cond.address);
} else if (key === 'province') {
cond.address.province = val;
cond.address.city = '';
cond.address.district = '';
} else if (key === 'city') {
cond.address.city = val;
cond.address.district = '';
} else {
cond.address.district = val;
}
renderFlowConfig();
autoSave();
}
/* ========== ZOOM ========== */
function zoomFlow(delta) {
currentZoom = Math.max(0.5, Math.min(2, currentZoom + delta));
document.getElementById('flow-editor').style.transform = 'scale(' + currentZoom + ')';
document.getElementById('flow-editor').style.transformOrigin = 'top center';
}
function resetZoom() { currentZoom = 1; zoomFlow(0); }
function focusFlow() { resetZoom(); }
/* ========== EXPORT / IMPORT ========== */
function sanitizeExportFilename(name) {
return String(name || '审批配置').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/\s+/g, '_').slice(0, 80) || '审批配置';
}
function downloadBlob(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
function buildStandaloneHtml(config) {
const clone = document.documentElement.cloneNode(true);
const boot = clone.querySelector('#app-boot');
if (boot) {
boot.textContent = [
'window.APP_PROJECT = ' + JSON.stringify(window.APP_PROJECT || { id: 'approval_of_design', label: '飞书审批配置' }) + ';',
"window.APP_MODE = 'standalone';",
'window.__INITIAL_CONFIG__ = ' + JSON.stringify(config) + ';',
"document.documentElement.setAttribute('data-app-mode', window.APP_MODE);"
].join('\n');
}
return '<!DOCTYPE html>\n' + clone.outerHTML;
}
async function exportConfiguredHtml() {
const btn = document.getElementById('btn-export-html');
const config = collectConfig();
config.savedAt = new Date().toISOString();
const filename = sanitizeExportFilename(config.approvalName) + '.html';
try {
if (btn) { btn.disabled = true; btn.textContent = '导出中…'; }
if (window.APP_MODE === 'server' && location.protocol.startsWith('http')) {
const res = await fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${res.status}`);
}
const blob = await res.blob();
downloadBlob(blob, filename);
} else {
downloadBlob(new Blob([buildStandaloneHtml(config)], { type: 'text/html;charset=utf-8' }), filename);
}
alert('已下载:' + filename + '\n该 HTML 文件已包含全部配置,可离线打开查看或继续编辑。');
} catch (e) {
alert('导出失败:' + (e.message || e));
} finally {
if (btn) { btn.disabled = false; btn.textContent = '导出 审批流设计文档'; }
}
}
function parseConfigFromHtmlText(html) {
const match = html.match(/window\.__INITIAL_CONFIG__\s*=\s*([\s\S]*?);\s*(?:\n|<\/script>)/);
if (!match) throw new Error('未在 HTML 中找到配置数据');
return JSON.parse(match[1]);
}
async function importHtmlFile(input) {
const file = input.files && input.files[0];
input.value = '';
if (!file) return;
try {
const html = await file.text();
let config = null;
if (window.APP_MODE === 'server' && location.protocol.startsWith('http')) {
const res = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html })
});
const result = await res.json().catch(() => ({}));
if (!res.ok || !result.ok) throw new Error(result.error || '导入失败');
config = result.config;
} else {
config = parseConfigFromHtmlText(html);
}
if (!config) throw new Error('配置为空');
applyConfig(config);
syncHeaderName();
renderFormFields();
renderFormConfig();
renderFlowEditor();
renderFlowConfig();
alert('已导入:' + (config.approvalName || '审批配置'));
} catch (e) {
alert('导入失败:' + (e.message || e));
}
}
function autoSave() {
/* 在线编辑过程保留在内存,点击「保存并下载 HTML」导出 */
}
async function loadConfig() {
const data = window.__INITIAL_CONFIG__;
if (data == null) return;
applyConfig(data);
}
</script>
</body>
</html>