4186 lines
227 KiB
HTML
4186 lines
227 KiB
HTML
<!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">▾</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">📄</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">▾</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">📝</span><span>单行文本</span></div>
|
||
<div class="widget-item" onclick="addFormField('multi_text')" draggable="true" ondragstart="dragWidget(event,'multi_text')"><span class="icon">📜</span><span>多行文本</span></div>
|
||
<div class="widget-item" onclick="addFormField('desc_text')" draggable="true" ondragstart="dragWidget(event,'desc_text')"><span class="icon">💬</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">▾</span></div>
|
||
</div>
|
||
<div class="widget-group-items">
|
||
<div class="widget-item" onclick="addFormField('number')" draggable="true" ondragstart="dragWidget(event,'number')"><span class="icon">🔢</span><span>数字</span></div>
|
||
<div class="widget-item" onclick="addFormField('amount')" draggable="true" ondragstart="dragWidget(event,'amount')"><span class="icon">💰</span><span>金额</span></div>
|
||
<div class="widget-item" onclick="addFormField('formula')" draggable="true" ondragstart="dragWidget(event,'formula')"><span class="icon">🖩</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">▾</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">◯</span><span>单选</span></div>
|
||
<div class="widget-item" onclick="addFormField('multi_choice')" draggable="true" ondragstart="dragWidget(event,'multi_choice')"><span class="icon">☑</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">▾</span></div>
|
||
</div>
|
||
<div class="widget-group-items">
|
||
<div class="widget-item" onclick="addFormField('date')" draggable="true" ondragstart="dragWidget(event,'date')"><span class="icon">📅</span><span>日期</span></div>
|
||
<div class="widget-item" onclick="addFormField('date_range')" draggable="true" ondragstart="dragWidget(event,'date_range')"><span class="icon">📆</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">▾</span></div>
|
||
</div>
|
||
<div class="widget-group-items">
|
||
<div class="widget-item" onclick="addFormField('detail')" draggable="true" ondragstart="dragWidget(event,'detail')"><span class="icon">🗏</span><span>明细/表格</span></div>
|
||
<div class="widget-item" onclick="addFormField('bitable')" draggable="true" ondragstart="dragWidget(event,'bitable')"><span class="icon">📊</span><span>引用多维表格</span></div>
|
||
<div class="widget-item" onclick="addFormField('image')" draggable="true" ondragstart="dragWidget(event,'image')"><span class="icon">📷</span><span>图片/视频</span></div>
|
||
<div class="widget-item" onclick="addFormField('attachment')" draggable="true" ondragstart="dragWidget(event,'attachment')"><span class="icon">📦</span><span>附件</span></div>
|
||
<div class="widget-item" onclick="addFormField('department')" draggable="true" ondragstart="dragWidget(event,'department')"><span class="icon">🏢</span><span>部门</span></div>
|
||
<div class="widget-item" onclick="addFormField('contact')" draggable="true" ondragstart="dragWidget(event,'contact')"><span class="icon">👤</span><span>联系人</span></div>
|
||
<div class="widget-item" onclick="addFormField('related_approval')" draggable="true" ondragstart="dragWidget(event,'related_approval')"><span class="icon">🔗</span><span>关联审批</span></div>
|
||
<div class="widget-item" onclick="addFormField('address')" draggable="true" ondragstart="dragWidget(event,'address')"><span class="icon">📍</span><span>地址</span></div>
|
||
<div class="widget-item" onclick="addFormField('location')" draggable="true" ondragstart="dragWidget(event,'location')"><span class="icon">🚩</span><span>定位</span></div>
|
||
<div class="widget-item" onclick="addFormField('bank_account')" draggable="true" ondragstart="dragWidget(event,'bank_account')"><span class="icon">💳</span><span>收款账户</span></div>
|
||
<div class="widget-item" onclick="addFormField('phone')" draggable="true" ondragstart="dragWidget(event,'phone')"><span class="icon">📞</span><span>电话</span></div>
|
||
<div class="widget-item" onclick="addFormField('serial_no')" draggable="true" ondragstart="dragWidget(event,'serial_no')"><span class="icon">📑</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">▾</span>
|
||
</div>
|
||
<div class="widget-group-items">
|
||
<div class="widget-item" onclick="addFlowNode('approver')"><span class="icon" style="color:var(--primary)">👤</span><span>审批人</span></div>
|
||
<div class="widget-item" onclick="addFlowNode('cc')"><span class="icon" style="color:var(--success)">✉</span><span>抄送人</span></div>
|
||
<div class="widget-item" onclick="addFlowNode('handler')"><span class="icon" style="color:var(--warning)">📝</span><span>办理人</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="widget-group">
|
||
<div class="widget-group-title" onclick="toggleWidgetGroup(this)">
|
||
<span>分支</span><span class="arrow">▾</span>
|
||
</div>
|
||
<div class="widget-group-items">
|
||
<div class="widget-item" onclick="addFlowNode('condition_branch')"><span class="icon" style="color:#8b5cf6">⇄</span><span>条件分支</span></div>
|
||
<div class="widget-item" onclick="addFlowNode('parallel_branch')"><span class="icon" style="color:#0ea5e9">≡</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: '📄', group: 'doc', hasPlaceholder: false },
|
||
single_text: { label: '单行文本', icon: '📝', group: 'text', hasPlaceholder: true },
|
||
multi_text: { label: '多行文本', icon: '📜', group: 'text', hasPlaceholder: true },
|
||
desc_text: { label: '说明', icon: '💬', group: 'text', hasPlaceholder: true },
|
||
number: { label: '数字', icon: '🔢', group: 'number', hasPlaceholder: true },
|
||
amount: { label: '金额', icon: '💰', group: 'number', hasPlaceholder: true },
|
||
formula: { label: '计算公式', icon: '🖩', group: 'number', hasPlaceholder: false },
|
||
single_choice: { label: '单选', icon: '◯', group: 'option', hasPlaceholder: false },
|
||
multi_choice: { label: '多选', icon: '☑', group: 'option', hasPlaceholder: false },
|
||
date: { label: '日期', icon: '📅', group: 'date', hasPlaceholder: true },
|
||
date_range: { label: '日期区间', icon: '📆', group: 'date', hasPlaceholder: true },
|
||
attachment: { label: '附件', icon: '📦', group: 'other', hasPlaceholder: false },
|
||
image: { label: '图片/视频', icon: '📷', group: 'other', hasPlaceholder: false },
|
||
detail: { label: '明细/表格', icon: '🗏', group: 'other', hasPlaceholder: false },
|
||
bitable: { label: '引用多维表格', icon: '📊', group: 'other', hasPlaceholder: false },
|
||
department: { label: '部门', icon: '🏢', group: 'other', hasPlaceholder: true },
|
||
contact: { label: '联系人', icon: '👤', group: 'other', hasPlaceholder: true },
|
||
address: { label: '地址', icon: '📍', group: 'other', hasPlaceholder: true },
|
||
related_approval:{label:'关联审批', icon: '🔗', group: 'other', hasPlaceholder: false },
|
||
location: { label: '定位', icon: '🚩', group: 'other', hasPlaceholder: false },
|
||
bank_account: { label: '收款账户', icon: '💳', group: 'other', hasPlaceholder: false },
|
||
phone: { label: '电话', icon: '📞', group: 'other', hasPlaceholder: true },
|
||
serial_no: { label: '流水号', icon: '📑', group: 'other', hasPlaceholder: false },
|
||
member: { label: '成员', icon: '👥', 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">▾</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)">▲</div>
|
||
<div class="field-action" onclick="event.stopPropagation();moveDetailField('${detailId}','${c.id}',1)">▼</div>
|
||
<div class="field-action delete" onclick="event.stopPropagation();deleteDetailField('${detailId}','${c.id}')">✕</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)">▲</div>
|
||
<div class="field-action" onclick="event.stopPropagation();moveField('${f.id}',1)">▼</div>
|
||
<div class="field-action delete" onclick="event.stopPropagation();deleteField('${f.id}')">✕</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})">✕</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">✕</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
/* ========== 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})">✕</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})">✕</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}">✕</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}')">✕</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)">👤</span> 审批人</div>
|
||
<div class="add-menu-item" onclick="insertFlowNode('${prevId}','cc')"><span style="color:var(--success)">✉</span> 抄送人</div>
|
||
<div class="add-menu-item" onclick="insertFlowNode('${prevId}','handler')"><span style="color:var(--warning)">📝</span> 办理人</div>
|
||
<div class="add-menu-item" onclick="insertFlowNode('${prevId}','condition_branch')"><span style="color:#8b5cf6">⇄</span> 条件分支</div>
|
||
<div class="add-menu-item" onclick="insertFlowNode('${prevId}','parallel_branch')"><span style="color:#0ea5e9">≡</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}')">✕</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>
|