Add visual approval flow designer
This commit is contained in:
366
app/approval/ApprovalDesigner.module.css
Normal file
366
app/approval/ApprovalDesigner.module.css
Normal file
@@ -0,0 +1,366 @@
|
||||
.designerShell {
|
||||
--navy: #142033;
|
||||
--text: #2a3548;
|
||||
--muted: #7d8798;
|
||||
--line: #e5e8ee;
|
||||
--blue: #3370ff;
|
||||
--blueSoft: #eaf1ff;
|
||||
--green: #25a66a;
|
||||
--greenSoft: #eaf8f1;
|
||||
--orange: #e79024;
|
||||
--orangeSoft: #fff4e5;
|
||||
--purple: #7656d6;
|
||||
--purpleSoft: #f0ecfd;
|
||||
min-width: 1040px;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #f4f6f9;
|
||||
color: var(--text);
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
height: 68px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 0 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 9px rgba(27, 38, 59, .04);
|
||||
}
|
||||
|
||||
.topbarLeft, .topbarActions, .processIdentity > div, .settingsHeader > div, .previewModal header > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.topbarLeft { gap: 12px; }
|
||||
.backButton {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #e2e6ec;
|
||||
border-radius: 8px;
|
||||
color: #6f7a8c;
|
||||
text-decoration: none;
|
||||
font-size: 18px;
|
||||
}
|
||||
.backButton:hover { border-color: #abc2fa; color: var(--blue); background: #f7f9ff; }
|
||||
.brandMark { position: relative; width: 25px; height: 25px; display: block; margin-left: 2px; transform: rotate(-11deg); }
|
||||
.brandMark i { position: absolute; bottom: 1px; display: block; width: 6px; border-radius: 4px; background: var(--blue); }
|
||||
.brandMark i:nth-child(1) { left: 1px; height: 12px; background: #7ca5ff; }
|
||||
.brandMark i:nth-child(2) { left: 9px; height: 22px; background: var(--navy); }
|
||||
.brandMark i:nth-child(3) { left: 17px; height: 16px; }
|
||||
.processIdentity { min-width: 260px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.processIdentity > div { gap: 8px; }
|
||||
.processIdentity input {
|
||||
width: 185px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--navy);
|
||||
font-size: 14px;
|
||||
font-weight: 750;
|
||||
}
|
||||
.processIdentity small { color: #8e97a6; font-size: 9px; }
|
||||
.draft, .published { padding: 3px 7px; border-radius: 5px; font-size: 8px; font-weight: 700; }
|
||||
.draft { background: var(--orangeSoft); color: var(--orange); }
|
||||
.published { background: var(--greenSoft); color: var(--green); }
|
||||
.topbarActions { gap: 8px; }
|
||||
.topbarActions button { min-height: 36px; padding: 0 13px; border-radius: 7px; cursor: pointer; font-size: 10px; font-weight: 650; }
|
||||
.quietButton { border: 1px solid #dde2e9; background: #fff; color: #5e6b7f; }
|
||||
.quietButton:hover { border-color: #b7c8e9; background: #f9fbff; color: var(--blue); }
|
||||
.publishButton { border: 1px solid var(--blue); background: var(--blue); color: #fff; box-shadow: 0 5px 13px rgba(51,112,255,.2); }
|
||||
.publishButton:hover { background: #235bd4; }
|
||||
|
||||
.workspace {
|
||||
height: calc(100vh - 68px);
|
||||
display: grid;
|
||||
grid-template-columns: 235px minmax(520px, 1fr) 326px;
|
||||
}
|
||||
|
||||
.palette, .propertyPanel { position: relative; z-index: 10; overflow-y: auto; background: #fff; }
|
||||
.palette { border-right: 1px solid var(--line); }
|
||||
.propertyPanel { border-left: 1px solid var(--line); }
|
||||
.paletteHeader {
|
||||
height: 68px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 17px;
|
||||
border-bottom: 1px solid #edf0f4;
|
||||
}
|
||||
.paletteHeader > div { display: flex; flex-direction: column; gap: 4px; }
|
||||
.paletteHeader strong { color: var(--navy); font-size: 13px; }
|
||||
.paletteHeader small { color: #98a1af; font-size: 9px; }
|
||||
.paletteHeader > button { display: none; border: 0; background: none; color: #8490a2; font-size: 18px; }
|
||||
.paletteSection { padding: 17px 13px 4px; }
|
||||
.paletteSection > p { margin: 0 4px 10px; color: #98a0ae; font-size: 9px; font-weight: 750; letter-spacing: .7px; }
|
||||
.paletteItem {
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #e5e9ef;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
cursor: grab;
|
||||
text-align: left;
|
||||
transition: border-color .16s, box-shadow .16s, transform .16s;
|
||||
}
|
||||
.paletteItem:hover { border-color: #a9c2fa; box-shadow: 0 7px 18px rgba(38,72,137,.08); transform: translateY(-1px); }
|
||||
.paletteItem:active { cursor: grabbing; }
|
||||
.paletteIcon, .nodeIcon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 8px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.paletteIcon { width: 35px; height: 35px; font-size: 15px; }
|
||||
.blue { background: var(--blueSoft); color: var(--blue); }
|
||||
.green { background: var(--greenSoft); color: var(--green); }
|
||||
.orange { background: var(--orangeSoft); color: var(--orange); }
|
||||
.purple { background: var(--purpleSoft); color: var(--purple); }
|
||||
.gray { background: #edf0f4; color: #697589; }
|
||||
.paletteItem > span:nth-child(2) { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||||
.paletteItem strong { color: #36445b; font-size: 10px; }
|
||||
.paletteItem small { overflow: hidden; color: #8e98a8; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.paletteItem > b { color: #a1a9b6; font-size: 15px; }
|
||||
.disabledItem { background: #fbfcfd; }
|
||||
.helpCard { display: flex; gap: 10px; margin: 20px 13px; padding: 13px; border: 1px solid #dbe5fb; border-radius: 9px; background: #f6f9ff; }
|
||||
.helpCard > span { width: 24px; height: 24px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: var(--blueSoft); color: var(--blue); font-size: 10px; font-weight: 800; }
|
||||
.helpCard div { min-width: 0; }
|
||||
.helpCard strong { color: #405273; font-size: 9px; }
|
||||
.helpCard p { margin: 4px 0 0; color: #77849a; font-size: 8px; line-height: 1.6; }
|
||||
|
||||
.canvasArea { min-width: 0; display: flex; flex-direction: column; background: #f2f4f8; }
|
||||
.canvasToolbar {
|
||||
height: 47px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #e2e6ec;
|
||||
background: rgba(255,255,255,.86);
|
||||
}
|
||||
.breadcrumb { min-width: 0; display: flex; align-items: center; gap: 8px; color: #8993a4; font-size: 9px; }
|
||||
.breadcrumb strong { overflow: hidden; color: #536077; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.breadcrumb b { color: #bdc3cc; font-weight: 400; }
|
||||
.canvasTools { display: flex; align-items: center; overflow: hidden; border: 1px solid #dde2e9; border-radius: 7px; background: #fff; }
|
||||
.canvasTools button { height: 29px; padding: 0 9px; border: 0; border-right: 1px solid #edf0f3; background: #fff; color: #647085; cursor: pointer; font-size: 9px; }
|
||||
.canvasTools button:hover { background: #f3f6fc; color: var(--blue); }
|
||||
.canvasTools span { min-width: 43px; color: #7e899a; font-size: 8px; text-align: center; }
|
||||
.mobilePaletteButton { display: none; padding: 6px 10px; border: 1px solid #bfd0f5; border-radius: 6px; background: #fff; color: var(--blue); font-size: 9px; }
|
||||
.canvasScroller { min-height: 0; flex: 1; overflow: auto; }
|
||||
.canvasGrid { min-width: 750px; min-height: 100%; padding: 42px 34px 120px; background: #f4f6f9; }
|
||||
.flowWrap {
|
||||
width: 680px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transform-origin: center top;
|
||||
transition: transform .18s ease;
|
||||
}
|
||||
.flowStep { width: 100%; display: flex; flex-direction: column; align-items: center; }
|
||||
.processNode {
|
||||
position: relative;
|
||||
width: 310px;
|
||||
min-height: 86px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 15px;
|
||||
border: 1px solid #dfe4eb;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 5px 16px rgba(28,42,68,.07);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color .16s, box-shadow .16s, transform .16s;
|
||||
}
|
||||
.processNode:hover { border-color: #a9c3fb; box-shadow: 0 9px 22px rgba(28,67,143,.1); transform: translateY(-1px); }
|
||||
.selectedNode { border: 2px solid var(--blue); box-shadow: 0 0 0 4px rgba(51,112,255,.1), 0 9px 22px rgba(28,67,143,.1); }
|
||||
.nodeIcon { width: 39px; height: 39px; font-size: 16px; }
|
||||
.nodeCopy { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 3px; }
|
||||
.nodeCopy small { color: #9aa3b0; font-size: 7px; font-weight: 750; letter-spacing: .4px; text-transform: uppercase; }
|
||||
.nodeCopy strong { overflow: hidden; color: #28374e; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nodeCopy em { overflow: hidden; color: #8791a1; font-size: 8px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.approverPreview { display: flex; align-items: center; flex: 0 0 auto; }
|
||||
.approverPreview i { width: 22px; height: 22px; display: grid; place-items: center; margin-left: -5px; border: 2px solid #fff; border-radius: 50%; background: #dce8ff; color: #3567c8; font-size: 7px; font-style: normal; font-weight: 700; }
|
||||
.approverPreview b { margin-left: 5px; color: #7f8999; font-size: 7px; font-weight: 600; }
|
||||
.nodeMore { position: absolute; top: 7px; right: 9px; color: #b0b6c0; font-size: 7px; letter-spacing: 1px; }
|
||||
.node_start, .node_end { width: 270px; min-height: 72px; }
|
||||
.connector { position: relative; width: 2px; height: 42px; background: #b8c1cf; }
|
||||
.connector::after { content: ""; position: absolute; bottom: -1px; left: -3px; width: 6px; height: 6px; border-right: 2px solid #9ca7b7; border-bottom: 2px solid #9ca7b7; transform: rotate(45deg); }
|
||||
.connector i { position: absolute; top: 16px; left: -3px; width: 8px; height: 8px; border: 2px solid #fff; border-radius: 50%; background: #b6c0cd; }
|
||||
|
||||
.conditionGroup { position: relative; width: 680px; padding: 0 18px 22px; border: 1px dashed transparent; border-radius: 13px; }
|
||||
.selectedCondition { border-color: #94b4f8; background: rgba(233,240,255,.5); }
|
||||
.conditionTitle {
|
||||
width: 310px;
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
margin: 0 auto;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid #f0d7b2;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 5px 16px rgba(28,42,68,.06);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.conditionTitle > span:nth-child(2) { flex: 1; display: flex; flex-direction: column; gap: 3px; }
|
||||
.conditionTitle small { color: var(--orange); font-size: 7px; font-weight: 750; }
|
||||
.conditionTitle strong { color: #2e3b50; font-size: 11px; }
|
||||
.conditionTitle > b { color: #a0a7b2; font-size: 8px; font-weight: 500; }
|
||||
.branchRail { position: relative; width: calc(50% + 2px); height: 37px; margin: 0 auto; border-right: 2px solid #b8c1cf; border-bottom: 2px solid #b8c1cf; border-left: 2px solid #b8c1cf; }
|
||||
.branchRail::before { content: ""; position: absolute; top: 0; left: 50%; width: 2px; height: 18px; background: #b8c1cf; }
|
||||
.branchGrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; }
|
||||
.branchLane { display: flex; flex-direction: column; align-items: center; }
|
||||
.branchLabel { width: 100%; min-height: 56px; padding: 9px 11px; border: 1px solid #e3e7ed; border-radius: 8px; background: #fff; }
|
||||
.branchLabel > span { width: 17px; height: 17px; display: grid; place-items: center; float: left; margin-right: 7px; border-radius: 4px; background: var(--orangeSoft); color: var(--orange); font-size: 7px; font-weight: 750; }
|
||||
.branchLabel strong { display: block; color: #4a5669; font-size: 9px; }
|
||||
.branchLabel code { display: block; margin: 5px 0 0 24px; color: #9a7b55; font-size: 7px; }
|
||||
.branchConnector { width: 2px; height: 23px; background: #b8c1cf; }
|
||||
.branchLane .processNode { width: 100%; }
|
||||
.branchJoin { position: relative; width: calc(50% + 2px); height: 37px; margin: 0 auto; border-right: 2px solid #b8c1cf; border-top: 2px solid #b8c1cf; border-left: 2px solid #b8c1cf; }
|
||||
.branchJoin i { position: absolute; bottom: -1px; left: 50%; width: 2px; height: 20px; background: #b8c1cf; }
|
||||
.canvasAdd { display: flex; align-items: center; gap: 7px; margin-top: 18px; padding: 7px 12px; border: 1px dashed #aeb9c9; border-radius: 8px; background: rgba(255,255,255,.75); color: #7a8799; cursor: pointer; font-size: 8px; }
|
||||
.canvasAdd:hover { border-color: var(--blue); color: var(--blue); }
|
||||
.canvasAdd span { font-size: 13px; }
|
||||
|
||||
.settingsHeader { min-height: 67px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; border-bottom: 1px solid #edf0f4; }
|
||||
.settingsHeader > div { gap: 10px; }
|
||||
.settingsHeader .nodeIcon { width: 33px; height: 33px; font-size: 13px; }
|
||||
.settingsHeader > div > span:last-child { display: flex; flex-direction: column; gap: 3px; }
|
||||
.settingsHeader strong { max-width: 190px; overflow: hidden; color: var(--navy); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.settingsHeader small { color: #98a1af; font-size: 7px; }
|
||||
.settingsHeader > button { width: 27px; height: 27px; border: 0; border-radius: 6px; background: #fff2f2; color: #d85656; cursor: pointer; }
|
||||
.settingsTabs { height: 42px; display: flex; padding: 0 13px; border-bottom: 1px solid #edf0f4; }
|
||||
.settingsTabs button { position: relative; flex: 1; border: 0; background: transparent; color: #8791a1; cursor: pointer; font-size: 9px; }
|
||||
.settingsTabs button.activeTab { color: var(--blue); font-weight: 700; }
|
||||
.settingsTabs button.activeTab::after { content: ""; position: absolute; right: 20%; bottom: -1px; left: 20%; height: 2px; border-radius: 2px; background: var(--blue); }
|
||||
.settingsBody { padding: 18px 16px 45px; }
|
||||
.field { display: flex; flex-direction: column; gap: 7px; margin-bottom: 16px; }
|
||||
.field > span, .settingLabel strong { color: #536076; font-size: 9px; font-weight: 700; }
|
||||
.field input, .field textarea {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #dde2e9;
|
||||
border-radius: 7px;
|
||||
outline: 0;
|
||||
background: #fff;
|
||||
color: #3e4b60;
|
||||
font: inherit;
|
||||
font-size: 9px;
|
||||
resize: vertical;
|
||||
}
|
||||
.field input:focus, .field textarea:focus { border-color: #8eb0fa; box-shadow: 0 0 0 3px rgba(51,112,255,.08); }
|
||||
.settingGroup, .approverSetting, .branchSettings { margin: 19px 0; padding-top: 17px; border-top: 1px solid #edf0f4; }
|
||||
.settingLabel { display: flex; flex-direction: column; gap: 3px; margin-bottom: 10px; }
|
||||
.settingLabel small { color: #99a2b0; font-size: 7px; }
|
||||
.segmented { display: flex; padding: 3px; border-radius: 7px; background: #f0f2f5; }
|
||||
.segmented button { flex: 1; height: 28px; border: 0; border-radius: 5px; background: transparent; color: #7e899b; cursor: pointer; font-size: 8px; }
|
||||
.segmented button.segmentActive { background: #fff; color: var(--blue); box-shadow: 0 2px 6px rgba(43,56,81,.08); font-weight: 700; }
|
||||
.peopleList { display: flex; flex-direction: column; gap: 7px; }
|
||||
.peopleList > div { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid #e5e9ef; border-radius: 7px; }
|
||||
.peopleList > div > span { width: 27px; height: 27px; display: grid; place-items: center; border-radius: 50%; background: var(--blueSoft); color: #3b68c6; font-size: 8px; font-weight: 700; }
|
||||
.peopleList p { flex: 1; display: flex; flex-direction: column; gap: 2px; margin: 0; }
|
||||
.peopleList p strong { color: #47556b; font-size: 9px; }
|
||||
.peopleList p small { color: #9ba3b0; font-size: 7px; }
|
||||
.peopleList button { border: 0; background: none; color: #a2aab6; cursor: pointer; }
|
||||
.addPeople { width: 100%; height: 34px; margin-top: 8px; border: 1px dashed #b8c4d7; border-radius: 7px; background: #fbfcfe; color: var(--blue); cursor: pointer; font-size: 8px; }
|
||||
.branchSettings > div { margin-bottom: 12px; padding: 11px; border: 1px solid #e5e9ef; border-radius: 8px; background: #fafbfc; }
|
||||
.branchSettings > div > div:first-child { display: flex; align-items: center; gap: 7px; margin-bottom: 12px; }
|
||||
.branchSettings > div > div:first-child span { width: 18px; height: 18px; display: grid; place-items: center; border-radius: 4px; background: var(--orangeSoft); color: var(--orange); font-size: 7px; font-weight: 700; }
|
||||
.branchSettings > div > div:first-child strong { color: #566276; font-size: 9px; }
|
||||
.branchSettings .field { margin-bottom: 10px; }
|
||||
.branchSettings .field:last-child { margin-bottom: 0; }
|
||||
.infoBox { display: flex; gap: 9px; padding: 11px; border: 1px solid #dbe5fa; border-radius: 8px; background: #f6f9ff; }
|
||||
.infoBox span { width: 20px; height: 20px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: var(--blueSoft); color: var(--blue); font-size: 8px; font-weight: 800; }
|
||||
.infoBox p { margin: 1px 0 0; color: #6e7c92; font-size: 8px; line-height: 1.6; }
|
||||
.permissions { border: 1px solid #e5e9ef; border-radius: 8px; overflow: hidden; }
|
||||
.permissions > div { min-height: 40px; display: grid; grid-template-columns: 1fr 42px 42px; align-items: center; padding: 0 9px; border-bottom: 1px solid #edf0f4; }
|
||||
.permissions > div:last-child { border-bottom: 0; }
|
||||
.permissions span, .permissions strong { color: #627086; font-size: 8px; }
|
||||
.permissions input { accent-color: var(--blue); }
|
||||
.permissionHead { background: #f7f8fa; }
|
||||
.advancedSettings { display: flex; flex-direction: column; gap: 10px; }
|
||||
.advancedSettings > label { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px; border: 1px solid #e5e9ef; border-radius: 8px; }
|
||||
.advancedSettings > label span { display: flex; flex-direction: column; gap: 4px; }
|
||||
.advancedSettings strong { color: #506076; font-size: 9px; }
|
||||
.advancedSettings small { color: #98a1af; font-size: 7px; }
|
||||
.advancedSettings input { width: 32px; height: 18px; accent-color: var(--blue); }
|
||||
.advancedSettings > button { height: 36px; border: 1px dashed #b8c4d7; border-radius: 7px; background: #fbfcfe; color: var(--blue); cursor: pointer; font-size: 8px; }
|
||||
.noSelection { min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 30px; text-align: center; }
|
||||
.noSelection > span { width: 52px; height: 52px; display: grid; place-items: center; border-radius: 50%; background: #f0f3f7; color: #9aa3b1; font-size: 22px; }
|
||||
.noSelection strong { margin: 14px 0 5px; color: #556176; font-size: 11px; }
|
||||
.noSelection p { margin: 0; color: #949dab; font-size: 8px; line-height: 1.6; }
|
||||
|
||||
.modalBackdrop { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 25px; background: rgba(16,25,42,.5); backdrop-filter: blur(4px); }
|
||||
.previewModal { width: 580px; max-height: calc(100vh - 50px); overflow: auto; border-radius: 13px; background: #fff; box-shadow: 0 30px 80px rgba(12,21,37,.28); }
|
||||
.previewModal header, .jsonDrawer header { display: flex; align-items: flex-start; justify-content: space-between; padding: 20px; border-bottom: 1px solid #edf0f4; }
|
||||
.previewModal header > div { gap: 11px; }
|
||||
.previewModal header > div > span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 9px; background: var(--blueSoft); color: var(--blue); }
|
||||
.previewModal h2, .jsonDrawer h2 { margin: 0 0 4px; color: var(--navy); font-size: 15px; }
|
||||
.previewModal header p, .jsonDrawer header p { margin: 0; color: #929cab; font-size: 8px; }
|
||||
.previewModal header > button, .jsonDrawer header > button { width: 28px; height: 28px; border: 0; border-radius: 50%; background: #f2f4f7; color: #778296; cursor: pointer; font-size: 16px; }
|
||||
.previewForm { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 15px 20px; border-bottom: 1px solid #edf0f4; background: #fafbfc; }
|
||||
.previewForm > div { display: flex; flex-direction: column; gap: 4px; }
|
||||
.previewForm span { color: #98a1af; font-size: 7px; }
|
||||
.previewForm strong { color: #4b586d; font-size: 9px; }
|
||||
.previewForm .hitBranch { color: var(--orange); }
|
||||
.previewRoute { padding: 20px 85px 25px; }
|
||||
.previewRoute > div { position: relative; display: flex; gap: 11px; min-height: 62px; }
|
||||
.previewDot { position: relative; z-index: 2; width: 25px; height: 25px; display: grid; place-items: center; flex: 0 0 auto; border: 2px solid #fff; border-radius: 50%; background: var(--blue); box-shadow: 0 0 0 1px #a9c2fa; color: #fff; font-size: 7px; font-weight: 700; }
|
||||
.previewRoute p { display: flex; flex-direction: column; gap: 4px; margin: 3px 0 0; }
|
||||
.previewRoute strong { color: #435168; font-size: 9px; }
|
||||
.previewRoute small { color: #9099a8; font-size: 7px; }
|
||||
.previewRoute i { position: absolute; top: 24px; left: 12px; bottom: 0; width: 1px; background: #cbd3df; }
|
||||
.previewModal footer, .jsonDrawer footer { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid #edf0f4; }
|
||||
.previewModal footer button, .jsonDrawer footer button { min-height: 34px; padding: 0 13px; border: 1px solid #dce1e8; border-radius: 7px; background: #fff; color: #657187; cursor: pointer; font-size: 8px; }
|
||||
.previewModal footer button:last-child, .jsonDrawer footer button:last-child { border-color: var(--blue); background: var(--blue); color: #fff; }
|
||||
.jsonDrawer { position: fixed; inset: 0 0 0 auto; width: 520px; display: flex; flex-direction: column; background: #fff; box-shadow: -20px 0 60px rgba(12,21,37,.18); }
|
||||
.jsonDrawer pre { flex: 1; margin: 0; padding: 18px; overflow: auto; background: #101a2c; color: #c9d9f5; font: 9px/1.7 "SFMono-Regular", Consolas, monospace; white-space: pre-wrap; }
|
||||
.toast { position: fixed; right: 25px; bottom: 24px; z-index: 100; min-width: 225px; display: flex; align-items: center; gap: 9px; padding: 12px 14px; border: 1px solid #dce3ec; border-radius: 9px; background: #fff; box-shadow: 0 16px 35px rgba(23,35,58,.18); color: #4b596e; font-size: 9px; animation: toastIn .2s ease-out; }
|
||||
.toast span { width: 19px; height: 19px; display: grid; place-items: center; border-radius: 50%; background: var(--greenSoft); color: var(--green); font-weight: 800; }
|
||||
@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.designerShell { min-width: 840px; }
|
||||
.workspace { grid-template-columns: minmax(500px, 1fr) 320px; }
|
||||
.palette { position: fixed; top: 68px; bottom: 0; left: 0; z-index: 50; width: 235px; transform: translateX(-100%); box-shadow: 14px 0 30px rgba(24,38,61,.13); transition: transform .2s ease; }
|
||||
.paletteOpen { transform: translateX(0); }
|
||||
.paletteHeader > button, .mobilePaletteButton { display: block; }
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.designerShell { min-width: 680px; }
|
||||
.topbar { padding: 0 13px; }
|
||||
.processIdentity { min-width: 210px; }
|
||||
.processIdentity input { width: 145px; }
|
||||
.topbarActions .quietButton:first-child { display: none; }
|
||||
.workspace { grid-template-columns: minmax(390px, 1fr) 290px; }
|
||||
.conditionGroup { width: 640px; }
|
||||
.flowWrap { width: 640px; }
|
||||
.canvasGrid { padding-right: 16px; padding-left: 16px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.processNode, .paletteItem, .flowWrap, .palette { transition: none; }
|
||||
.toast { animation: none; }
|
||||
}
|
||||
300
app/approval/page.tsx
Normal file
300
app/approval/page.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import defaultFlow from "../data/approval-flow.json";
|
||||
import styles from "./ApprovalDesigner.module.css";
|
||||
|
||||
type NodeType = "start" | "approval" | "condition" | "cc" | "end";
|
||||
type ApprovalMode = "single" | "any" | "all";
|
||||
|
||||
type FlowNode = {
|
||||
id: string;
|
||||
type: NodeType;
|
||||
title: string;
|
||||
description: string;
|
||||
approvers: string[];
|
||||
mode: ApprovalMode;
|
||||
allowTransfer?: boolean;
|
||||
autoPass?: boolean;
|
||||
branches?: Branch[];
|
||||
};
|
||||
|
||||
type Branch = {
|
||||
id: string;
|
||||
label: string;
|
||||
expression: string;
|
||||
node: FlowNode;
|
||||
};
|
||||
|
||||
type FlowData = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
version: string;
|
||||
updatedAt: string;
|
||||
nodes: FlowNode[];
|
||||
};
|
||||
|
||||
const library: Array<{ type: NodeType; label: string; description: string; icon: string; tone: string }> = [
|
||||
{ type: "approval", label: "审批人", description: "指定成员或角色审批", icon: "✓", tone: "blue" },
|
||||
{ type: "condition", label: "条件分支", description: "根据表单字段自动分流", icon: "⑂", tone: "orange" },
|
||||
{ type: "cc", label: "抄送人", description: "将审批结果通知成员", icon: "◎", tone: "purple" },
|
||||
];
|
||||
|
||||
const people = ["直属主管", "部门负责人", "财务负责人", "预算管理员", "采购专员", "法务负责人"];
|
||||
|
||||
export default function ApprovalDesignerPage() {
|
||||
const [flow, setFlow] = useState<FlowData>(defaultFlow as unknown as FlowData);
|
||||
const [selectedId, setSelectedId] = useState("approval-1");
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [jsonOpen, setJsonOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [toast, setToast] = useState("");
|
||||
const [saved, setSaved] = useState(true);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem("orbit-approval-flow");
|
||||
if (stored) {
|
||||
try { setFlow(JSON.parse(stored)); }
|
||||
catch { window.localStorage.removeItem("orbit-approval-flow"); }
|
||||
}
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
setSaved(false);
|
||||
const timer = window.setTimeout(() => {
|
||||
window.localStorage.setItem("orbit-approval-flow", JSON.stringify(flow));
|
||||
setSaved(true);
|
||||
}, 500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [flow, loaded]);
|
||||
|
||||
const selectedNode = useMemo(() => flow.nodes.find((node) => node.id === selectedId), [flow.nodes, selectedId]);
|
||||
|
||||
const notify = (message: string) => {
|
||||
setToast(message);
|
||||
window.setTimeout(() => setToast(""), 2400);
|
||||
};
|
||||
|
||||
const updateNode = (patch: Partial<FlowNode>) => {
|
||||
setFlow((current) => ({
|
||||
...current,
|
||||
nodes: current.nodes.map((node) => node.id === selectedId ? { ...node, ...patch } : node),
|
||||
updatedAt: new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||
}));
|
||||
};
|
||||
|
||||
const addNode = (type: NodeType) => {
|
||||
const id = `${type}-${Date.now()}`;
|
||||
const common = { id, type, approvers: [] as string[], mode: "single" as ApprovalMode };
|
||||
let node: FlowNode;
|
||||
if (type === "approval") node = { ...common, title: "新审批节点", description: "请选择审批人", approvers: ["直属主管"], allowTransfer: true };
|
||||
else if (type === "cc") node = { ...common, title: "新抄送节点", description: "审批通过后自动抄送", approvers: ["申请人"] };
|
||||
else node = {
|
||||
...common,
|
||||
title: "新条件分支",
|
||||
description: "根据表单字段设置条件",
|
||||
branches: [
|
||||
{ id: `${id}-a`, label: "条件 1", expression: "totalAmount >= 1000", node: { ...common, id: `${id}-node-a`, type: "approval", title: "条件审批人", description: "满足条件时执行", approvers: ["部门负责人"] } },
|
||||
{ id: `${id}-b`, label: "其他情况", expression: "otherwise", node: { ...common, id: `${id}-node-b`, type: "cc", title: "默认抄送", description: "未满足条件时执行", approvers: ["申请人"] } },
|
||||
],
|
||||
};
|
||||
setFlow((current) => {
|
||||
const endIndex = current.nodes.findIndex((item) => item.type === "end");
|
||||
const nodes = [...current.nodes];
|
||||
nodes.splice(endIndex < 0 ? nodes.length : endIndex, 0, node);
|
||||
return { ...current, nodes, updatedAt: new Date().toLocaleString("zh-CN", { hour12: false }) };
|
||||
});
|
||||
setSelectedId(id);
|
||||
setPaletteOpen(false);
|
||||
notify(`已添加${type === "approval" ? "审批" : type === "condition" ? "条件" : "抄送"}节点`);
|
||||
};
|
||||
|
||||
const deleteNode = () => {
|
||||
if (!selectedNode || selectedNode.type === "start" || selectedNode.type === "end") return;
|
||||
setFlow((current) => ({ ...current, nodes: current.nodes.filter((node) => node.id !== selectedId) }));
|
||||
setSelectedId("start-1");
|
||||
notify("节点已删除");
|
||||
};
|
||||
|
||||
const validateFlow = () => {
|
||||
const emptyApprovers = flow.nodes.filter((node) => (node.type === "approval" || node.type === "cc") && node.approvers.length === 0);
|
||||
const invalidBranches = flow.nodes.filter((node) => node.type === "condition" && (!node.branches || node.branches.length < 2));
|
||||
if (emptyApprovers.length || invalidBranches.length) notify(`发现 ${emptyApprovers.length + invalidBranches.length} 项配置需要完善`);
|
||||
else notify(`流程校验通过,共 ${flow.nodes.length} 个主节点`);
|
||||
};
|
||||
|
||||
const publish = () => {
|
||||
window.localStorage.setItem("orbit-approval-flow", JSON.stringify({ ...flow, status: "published" }));
|
||||
setFlow((current) => ({ ...current, status: "published" }));
|
||||
notify("演示流程已发布到本地");
|
||||
};
|
||||
|
||||
const resetFlow = () => {
|
||||
setFlow(defaultFlow as unknown as FlowData);
|
||||
setSelectedId("approval-1");
|
||||
window.localStorage.removeItem("orbit-approval-flow");
|
||||
notify("已恢复 JSON 示例流程");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.designerShell}>
|
||||
<header className={styles.topbar}>
|
||||
<div className={styles.topbarLeft}>
|
||||
<Link href="/" className={styles.backButton} aria-label="返回后台模板">←</Link>
|
||||
<span className={styles.brandMark}><i /><i /><i /></span>
|
||||
<div className={styles.processIdentity}>
|
||||
<div><input value={flow.name} onChange={(event) => setFlow((current) => ({ ...current, name: event.target.value }))} aria-label="流程名称" /><span className={flow.status === "published" ? styles.published : styles.draft}>{flow.status === "published" ? "已发布" : "草稿"}</span></div>
|
||||
<small>{saved ? "✓ 已自动保存到本地" : "正在保存…"} · {flow.version}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.topbarActions}>
|
||||
<button className={styles.quietButton} onClick={() => setJsonOpen(true)}>⌘ JSON</button>
|
||||
<button className={styles.quietButton} onClick={validateFlow}>✓ 校验流程</button>
|
||||
<button className={styles.quietButton} onClick={() => setPreviewOpen(true)}>▷ 预览</button>
|
||||
<button className={styles.publishButton} onClick={publish}>发布流程</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.workspace}>
|
||||
<aside className={`${styles.palette} ${paletteOpen ? styles.paletteOpen : ""}`}>
|
||||
<div className={styles.paletteHeader}><div><strong>节点库</strong><small>拖入画布或点击添加</small></div><button onClick={() => setPaletteOpen(false)}>×</button></div>
|
||||
<div className={styles.paletteSection}>
|
||||
<p>流程节点</p>
|
||||
{library.map((item) => (
|
||||
<button key={item.type} className={styles.paletteItem} draggable onDragStart={(event) => event.dataTransfer.setData("nodeType", item.type)} onClick={() => addNode(item.type)}>
|
||||
<span className={`${styles.paletteIcon} ${styles[item.tone]}`}>{item.icon}</span>
|
||||
<span><strong>{item.label}</strong><small>{item.description}</small></span>
|
||||
<b>+</b>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.paletteSection}>
|
||||
<p>自动化节点</p>
|
||||
<button className={`${styles.paletteItem} ${styles.disabledItem}`} onClick={() => notify("自动化节点为后续扩展示例")}><span className={`${styles.paletteIcon} ${styles.green}`}>↯</span><span><strong>发送通知</strong><small>通过模板发送消息</small></span><b>+</b></button>
|
||||
<button className={`${styles.paletteItem} ${styles.disabledItem}`} onClick={() => notify("数据操作节点为后续扩展示例")}><span className={`${styles.paletteIcon} ${styles.gray}`}>↻</span><span><strong>更新数据</strong><small>审批后更新业务字段</small></span><b>+</b></button>
|
||||
</div>
|
||||
<div className={styles.helpCard}><span>?</span><div><strong>设计提示</strong><p>审批节点支持或签、会签;条件节点可根据表单字段分流。</p></div></div>
|
||||
</aside>
|
||||
|
||||
<main className={styles.canvasArea} onDragOver={(event) => event.preventDefault()} onDrop={(event) => { const type = event.dataTransfer.getData("nodeType") as NodeType; if (type) addNode(type); }}>
|
||||
<div className={styles.canvasToolbar}>
|
||||
<button className={styles.mobilePaletteButton} onClick={() => setPaletteOpen(true)}>+ 节点库</button>
|
||||
<div className={styles.breadcrumb}><span>审批管理</span><b>/</b><span>流程设计</span><b>/</b><strong>{flow.name}</strong></div>
|
||||
<div className={styles.canvasTools}><button onClick={() => setZoom(Math.max(.8, zoom - .1))}>−</button><span>{Math.round(zoom * 100)}%</span><button onClick={() => setZoom(Math.min(1.2, zoom + .1))}>+</button><button onClick={() => setZoom(1)}>适应画布</button></div>
|
||||
</div>
|
||||
<div className={styles.canvasScroller}>
|
||||
<div className={styles.canvasGrid}>
|
||||
<div className={styles.flowWrap} style={{ transform: `scale(${zoom})` }}>
|
||||
{flow.nodes.map((node, index) => (
|
||||
<div className={styles.flowStep} key={node.id}>
|
||||
{node.type === "condition" ? (
|
||||
<ConditionNode node={node} selected={selectedId === node.id} onSelect={() => setSelectedId(node.id)} />
|
||||
) : (
|
||||
<ProcessNode node={node} selected={selectedId === node.id} onSelect={() => setSelectedId(node.id)} />
|
||||
)}
|
||||
{index < flow.nodes.length - 1 && <div className={styles.connector}><i /></div>}
|
||||
</div>
|
||||
))}
|
||||
<button className={styles.canvasAdd} onClick={() => addNode("approval")}><span>+</span>添加审批节点</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className={styles.propertyPanel}>
|
||||
{selectedNode ? <NodeSettings node={selectedNode} updateNode={updateNode} deleteNode={deleteNode} notify={notify} /> : <div className={styles.noSelection}><span>⌁</span><strong>选择一个节点</strong><p>点击画布中的节点后,可在这里配置审批规则。</p></div>}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{previewOpen && <PreviewModal flow={flow} onClose={() => setPreviewOpen(false)} />}
|
||||
{jsonOpen && <JsonDrawer flow={flow} resetFlow={resetFlow} onClose={() => setJsonOpen(false)} notify={notify} />}
|
||||
{toast && <div className={styles.toast}><span>✓</span>{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProcessNode({ node, selected, onSelect }: { node: FlowNode; selected: boolean; onSelect: () => void }) {
|
||||
const meta = {
|
||||
start: { icon: "▶", tone: "green", eyebrow: "流程发起" },
|
||||
approval: { icon: "✓", tone: "blue", eyebrow: node.mode === "all" ? "会签审批" : node.mode === "any" ? "或签审批" : "单人审批" },
|
||||
cc: { icon: "◎", tone: "purple", eyebrow: "抄送通知" },
|
||||
end: { icon: "■", tone: "gray", eyebrow: "流程完成" },
|
||||
condition: { icon: "⑂", tone: "orange", eyebrow: "条件分支" },
|
||||
}[node.type];
|
||||
return (
|
||||
<button className={`${styles.processNode} ${selected ? styles.selectedNode : ""} ${styles[`node_${node.type}`]}`} onClick={onSelect}>
|
||||
<span className={`${styles.nodeIcon} ${styles[meta.tone]}`}>{meta.icon}</span>
|
||||
<span className={styles.nodeCopy}><small>{meta.eyebrow}</small><strong>{node.title}</strong><em>{node.description}</em></span>
|
||||
{node.approvers.length > 0 && <span className={styles.approverPreview}>{node.approvers.slice(0, 2).map((person) => <i key={person}>{person.slice(0, 1)}</i>)}<b>{node.approvers.length} 人</b></span>}
|
||||
{node.type !== "start" && node.type !== "end" && <span className={styles.nodeMore}>•••</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionNode({ node, selected, onSelect }: { node: FlowNode; selected: boolean; onSelect: () => void }) {
|
||||
return (
|
||||
<div className={`${styles.conditionGroup} ${selected ? styles.selectedCondition : ""}`}>
|
||||
<button className={styles.conditionTitle} onClick={onSelect}><span className={`${styles.nodeIcon} ${styles.orange}`}>⑂</span><span><small>条件分支</small><strong>{node.title}</strong></span><b>点击配置规则 →</b></button>
|
||||
<div className={styles.branchRail} />
|
||||
<div className={styles.branchGrid}>
|
||||
{node.branches?.map((branch, index) => (
|
||||
<div className={styles.branchLane} key={branch.id}>
|
||||
<div className={styles.branchLabel}><span>{index + 1}</span><strong>{branch.label}</strong><code>{branch.expression}</code></div>
|
||||
<div className={styles.branchConnector} />
|
||||
<ProcessNode node={branch.node} selected={false} onSelect={onSelect} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.branchJoin}><i /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeSettings({ node, updateNode, deleteNode, notify }: { node: FlowNode; updateNode: (patch: Partial<FlowNode>) => void; deleteNode: () => void; notify: (message: string) => void }) {
|
||||
const [tab, setTab] = useState("节点设置");
|
||||
const addPerson = () => {
|
||||
const next = people.find((person) => !node.approvers.includes(person));
|
||||
if (!next) return notify("可选示例审批人已全部添加");
|
||||
updateNode({ approvers: [...node.approvers, next] });
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className={styles.settingsHeader}><div><span className={`${styles.nodeIcon} ${styles[node.type === "condition" ? "orange" : node.type === "cc" ? "purple" : node.type === "start" ? "green" : "blue"]}`}>{node.type === "condition" ? "⑂" : node.type === "cc" ? "◎" : "✓"}</span><span><strong>{node.title}</strong><small>节点 ID · {node.id}</small></span></div>{node.type !== "start" && node.type !== "end" && <button onClick={deleteNode}>♲</button>}</div>
|
||||
<div className={styles.settingsTabs}>{["节点设置", "表单权限", "高级"].map((item) => <button key={item} className={tab === item ? styles.activeTab : ""} onClick={() => setTab(item)}>{item}</button>)}</div>
|
||||
<div className={styles.settingsBody}>
|
||||
{tab === "节点设置" && <>
|
||||
<label className={styles.field}><span>节点名称</span><input value={node.title} onChange={(event) => updateNode({ title: event.target.value })} /></label>
|
||||
<label className={styles.field}><span>节点说明</span><textarea value={node.description} onChange={(event) => updateNode({ description: event.target.value })} rows={3} /></label>
|
||||
{node.type === "approval" && <>
|
||||
<div className={styles.settingGroup}><div className={styles.settingLabel}><strong>审批方式</strong><small>多人审批时的通过规则</small></div><div className={styles.segmented}>{[["single", "单人"], ["any", "或签"], ["all", "会签"]].map(([value, label]) => <button key={value} className={node.mode === value ? styles.segmentActive : ""} onClick={() => updateNode({ mode: value as ApprovalMode })}>{label}</button>)}</div></div>
|
||||
<ApproverPicker node={node} updateNode={updateNode} addPerson={addPerson} />
|
||||
</>}
|
||||
{node.type === "cc" && <ApproverPicker node={node} updateNode={updateNode} addPerson={addPerson} title="抄送成员" />}
|
||||
{node.type === "condition" && <div className={styles.branchSettings}>{node.branches?.map((branch, index) => <div key={branch.id}><div><span>{index + 1}</span><strong>分支 {index + 1}</strong></div><label className={styles.field}><span>条件名称</span><input value={branch.label} onChange={(event) => updateNode({ branches: node.branches?.map((item) => item.id === branch.id ? { ...item, label: event.target.value } : item) })} /></label><label className={styles.field}><span>条件表达式</span><input value={branch.expression} onChange={(event) => updateNode({ branches: node.branches?.map((item) => item.id === branch.id ? { ...item, expression: event.target.value } : item) })} /></label></div>)}</div>}
|
||||
{(node.type === "start" || node.type === "end") && <div className={styles.infoBox}><span>i</span><p>{node.type === "start" ? "发起节点用于设置流程适用范围和发起人权限。" : "结束节点会汇总审批结果,并生成完整的流程记录。"}</p></div>}
|
||||
</>}
|
||||
{tab === "表单权限" && <div className={styles.permissions}><div className={styles.permissionHead}><span>表单字段</span><span>可见</span><span>可编辑</span></div>{["采购事由", "采购金额", "费用归属", "附件凭证"].map((field, index) => <div key={field}><strong>{field}</strong><input type="checkbox" defaultChecked /><input type="checkbox" defaultChecked={index < 2} /></div>)}</div>}
|
||||
{tab === "高级" && <div className={styles.advancedSettings}><label><span><strong>允许转交</strong><small>审批人可将任务转给其他成员</small></span><input type="checkbox" checked={node.allowTransfer ?? true} onChange={(event) => updateNode({ allowTransfer: event.target.checked })} /></label><label><span><strong>相同审批人自动通过</strong><small>与上一节点审批人相同时跳过</small></span><input type="checkbox" checked={node.autoPass ?? false} onChange={(event) => updateNode({ autoPass: event.target.checked })} /></label><button onClick={() => notify("超时规则已添加为演示配置")}>+ 添加超时处理规则</button></div>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ApproverPicker({ node, updateNode, addPerson, title = "审批人" }: { node: FlowNode; updateNode: (patch: Partial<FlowNode>) => void; addPerson: () => void; title?: string }) {
|
||||
return <div className={styles.approverSetting}><div className={styles.settingLabel}><strong>{title}</strong><small>可选择成员、角色或主管</small></div><div className={styles.peopleList}>{node.approvers.map((person) => <div key={person}><span>{person.slice(0, 1)}</span><p><strong>{person}</strong><small>{person.includes("负责人") ? "角色" : "动态成员"}</small></p><button onClick={() => updateNode({ approvers: node.approvers.filter((item) => item !== person) })}>×</button></div>)}</div><button className={styles.addPeople} onClick={addPerson}>+ 添加审批人</button></div>;
|
||||
}
|
||||
|
||||
function PreviewModal({ flow, onClose }: { flow: FlowData; onClose: () => void }) {
|
||||
return <div className={styles.modalBackdrop} onMouseDown={onClose}><div className={styles.previewModal} onMouseDown={(event) => event.stopPropagation()}><header><div><span>▷</span><div><h2>流程预览</h2><p>模拟员工提交一笔 8,600 元采购申请</p></div></div><button onClick={onClose}>×</button></header><div className={styles.previewForm}><div><span>申请人</span><strong>林知夏 · 产品设计部</strong></div><div><span>采购金额</span><strong>¥ 8,600.00</strong></div><div><span>命中分支</span><strong className={styles.hitBranch}>金额 ≥ 5,000 元</strong></div></div><div className={styles.previewRoute}>{flow.nodes.map((node, index) => <div key={node.id}><span className={styles.previewDot}>{index + 1}</span><p><strong>{node.title}</strong><small>{node.type === "condition" ? "命中高金额分支" : node.approvers.join("、") || node.description}</small></p>{index < flow.nodes.length - 1 && <i />}</div>)}</div><footer><button onClick={onClose}>返回编辑</button><button onClick={onClose}>确认流程</button></footer></div></div>;
|
||||
}
|
||||
|
||||
function JsonDrawer({ flow, resetFlow, onClose, notify }: { flow: FlowData; resetFlow: () => void; onClose: () => void; notify: (message: string) => void }) {
|
||||
return <div className={styles.modalBackdrop} onMouseDown={onClose}><aside className={styles.jsonDrawer} onMouseDown={(event) => event.stopPropagation()}><header><div><h2>JSON 数据</h2><p>当前流程的纯前端数据结构</p></div><button onClick={onClose}>×</button></header><pre>{JSON.stringify(flow, null, 2)}</pre><footer><button onClick={resetFlow}>恢复示例</button><button onClick={() => { navigator.clipboard?.writeText(JSON.stringify(flow, null, 2)); notify("JSON 已复制"); }}>复制 JSON</button></footer></aside></div>;
|
||||
}
|
||||
Reference in New Issue
Block a user