ProcessDesigner.tsx 181 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298
  1. import React, { useState, useCallback, useRef, useEffect } from 'react';
  2. import { useNavigate, useLocation } from 'react-router-dom';
  3. import ReactFlow, {
  4. Node,
  5. Edge,
  6. addEdge,
  7. Connection,
  8. useNodesState,
  9. useEdgesState,
  10. Controls,
  11. Background,
  12. MiniMap,
  13. NodeTypes,
  14. BackgroundVariant,
  15. Handle,
  16. Position,
  17. ConnectionMode,
  18. EdgeLabelRenderer,
  19. BaseEdge,
  20. getStraightPath,
  21. getBezierPath,
  22. getSmoothStepPath,
  23. EdgeTypes,
  24. type NodeChange,
  25. } from 'reactflow';
  26. import 'reactflow/dist/style.css';
  27. import {
  28. SaveOutlined,
  29. ArrowLeftOutlined,
  30. UndoOutlined,
  31. RedoOutlined,
  32. ToolOutlined,
  33. CheckCircleOutlined,
  34. FileTextOutlined,
  35. EditOutlined,
  36. SafetyOutlined,
  37. UnlockOutlined,
  38. LockOutlined,
  39. CheckSquareOutlined,
  40. CloseOutlined,
  41. MenuFoldOutlined,
  42. MenuUnfoldOutlined,
  43. ZoomInOutlined,
  44. ZoomOutOutlined,
  45. // 更多图标用于选择器
  46. HomeOutlined,
  47. UserOutlined,
  48. TeamOutlined,
  49. SettingOutlined,
  50. FolderOutlined,
  51. FileOutlined,
  52. FolderOpenOutlined,
  53. DatabaseOutlined,
  54. CloudOutlined,
  55. ThunderboltOutlined,
  56. FireOutlined,
  57. RocketOutlined,
  58. StarOutlined,
  59. HeartOutlined,
  60. BellOutlined,
  61. MessageOutlined,
  62. PhoneOutlined,
  63. MailOutlined,
  64. CalendarOutlined,
  65. ClockCircleOutlined,
  66. SearchOutlined,
  67. PlusOutlined,
  68. MinusOutlined,
  69. DeleteOutlined,
  70. EditOutlined as EditIconOutlined,
  71. EyeOutlined,
  72. EyeInvisibleOutlined,
  73. DownloadOutlined,
  74. UploadOutlined,
  75. ReloadOutlined,
  76. PlayCircleOutlined,
  77. PauseCircleOutlined,
  78. StopOutlined,
  79. CheckOutlined,
  80. CloseCircleOutlined,
  81. WarningOutlined,
  82. InfoCircleOutlined,
  83. QuestionCircleOutlined,
  84. LinkOutlined,
  85. ShareAltOutlined,
  86. CopyOutlined,
  87. ScissorOutlined,
  88. PrinterOutlined,
  89. ShoppingCartOutlined,
  90. ShoppingOutlined,
  91. GiftOutlined,
  92. TrophyOutlined,
  93. CrownOutlined,
  94. BulbOutlined,
  95. ExperimentOutlined,
  96. BugOutlined,
  97. CodeOutlined,
  98. ApiOutlined,
  99. AppstoreOutlined,
  100. BarsOutlined,
  101. MenuOutlined,
  102. LayoutOutlined,
  103. TableOutlined,
  104. UnorderedListOutlined,
  105. OrderedListOutlined,
  106. PictureOutlined,
  107. VideoCameraOutlined,
  108. SoundOutlined,
  109. CustomerServiceOutlined,
  110. GlobalOutlined,
  111. EnvironmentOutlined,
  112. CompassOutlined,
  113. CarOutlined,
  114. BankOutlined,
  115. ShopOutlined,
  116. MedicineBoxOutlined,
  117. SafetyCertificateOutlined,
  118. InsuranceOutlined,
  119. FileProtectOutlined,
  120. FileSyncOutlined,
  121. FileSearchOutlined,
  122. FileAddOutlined,
  123. FileExcelOutlined,
  124. FilePdfOutlined,
  125. FileWordOutlined,
  126. FileImageOutlined,
  127. FileZipOutlined,
  128. FolderAddOutlined,
  129. FolderViewOutlined,
  130. ProjectOutlined,
  131. BuildOutlined,
  132. ToolOutlined as ToolIconOutlined,
  133. RobotOutlined,
  134. BugOutlined as BugIconOutlined,
  135. ExperimentOutlined as ExperimentIconOutlined,
  136. FireOutlined as FireIconOutlined,
  137. ThunderboltOutlined as ThunderboltIconOutlined,
  138. } from '@ant-design/icons';
  139. import { Button, Input, Select, Checkbox, Tabs, Modal, Dropdown, Popover, message, Card, Alert, InputNumber, Radio, DatePicker, Form as AntdForm, Cascader, Upload, Switch, Tooltip } from 'antd';
  140. import type { MenuProps } from 'antd';
  141. import { toast } from 'sonner';
  142. import { workflowDesignApi, WorkflowDesignVO } from '../api/WorkflowDesign';
  143. import { userApi } from '../api/user';
  144. import { UserVO } from '../types';
  145. import { segregationPointApi, SegregationPointVO } from '../api/spm';
  146. import { getFormPage, getForm, FormVO } from '../api/bpm/form';
  147. import { setConfAndFields2, FormCreateData } from '../utils/formCreate';
  148. import { dictDataApi, DictDataVO } from '../api/DictData';
  149. // 节点配置
  150. const nodeConfigs = [
  151. {
  152. type: 'createJob',
  153. label: '创建作业',
  154. icon: ToolOutlined,
  155. bgColor: 'bg-white',
  156. bgColorCustom: '#ffffff',
  157. iconColor: 'text-blue-600',
  158. iconColorCustom: '#165dff',
  159. borderColor: 'border-blue-100',
  160. },
  161. {
  162. type: 'confirm',
  163. label: '确认',
  164. icon: CheckCircleOutlined,
  165. bgColor: 'bg-white',
  166. bgColorCustom: '#ffffff',
  167. iconColor: 'text-green-600',
  168. iconColorCustom: '#36d399',
  169. borderColor: 'border-green-100',
  170. },
  171. {
  172. type: 'review',
  173. label: '审核',
  174. icon: FileTextOutlined,
  175. bgColor: 'bg-white',
  176. bgColorCustom: '#ffffff',
  177. iconColor: 'text-orange-600',
  178. iconColorCustom: '#fb923c',
  179. borderColor: 'border-orange-100',
  180. },
  181. {
  182. type: 'inputInfo',
  183. label: '录入信息',
  184. icon: EditOutlined,
  185. bgColor: 'bg-white',
  186. bgColorCustom: '#ffffff',
  187. iconColor: 'text-purple-600',
  188. iconColorCustom: '#9665ff',
  189. borderColor: 'border-purple-100',
  190. },
  191. {
  192. type: 'isolation',
  193. label: '隔离/方案',
  194. icon: SafetyOutlined,
  195. bgColor: 'bg-white',
  196. bgColorCustom: '#ffffff',
  197. iconColor: 'text-red-600',
  198. iconColorCustom: '#f87272',
  199. borderColor: 'border-red-100',
  200. },
  201. {
  202. type: 'releaseIsolation',
  203. label: '解除隔离',
  204. icon: UnlockOutlined,
  205. bgColor: 'bg-white',
  206. bgColorCustom: '#ffffff',
  207. iconColor: 'text-yellow-600',
  208. iconColorCustom: '#38bdf8',
  209. borderColor: 'border-yellow-100',
  210. },
  211. {
  212. type: 'returnLock',
  213. label: '还锁',
  214. icon: LockOutlined,
  215. bgColor: 'bg-white',
  216. bgColorCustom: '#ffffff',
  217. iconColor: 'text-indigo-600',
  218. iconColorCustom: '#6b7280',
  219. borderColor: 'border-indigo-100',
  220. },
  221. {
  222. type: 'complete',
  223. label: '完成/结束',
  224. icon: CheckSquareOutlined,
  225. bgColor: 'bg-white',
  226. bgColorCustom: '#ffffff',
  227. iconColor: 'text-gray-600',
  228. iconColorCustom: '#10b981',
  229. borderColor: 'border-gray-100',
  230. },
  231. ];
  232. // 可选的图标列表(用于图标选择器)
  233. const availableIcons = [
  234. { name: 'ToolOutlined', component: ToolOutlined, label: '工具' },
  235. { name: 'CheckCircleOutlined', component: CheckCircleOutlined, label: '确认' },
  236. { name: 'FileTextOutlined', component: FileTextOutlined, label: '文件' },
  237. { name: 'EditOutlined', component: EditOutlined, label: '编辑' },
  238. { name: 'SafetyOutlined', component: SafetyOutlined, label: '安全' },
  239. { name: 'UnlockOutlined', component: UnlockOutlined, label: '解锁' },
  240. { name: 'LockOutlined', component: LockOutlined, label: '锁定' },
  241. { name: 'CheckSquareOutlined', component: CheckSquareOutlined, label: '完成' },
  242. { name: 'HomeOutlined', component: HomeOutlined, label: '首页' },
  243. { name: 'UserOutlined', component: UserOutlined, label: '用户' },
  244. { name: 'TeamOutlined', component: TeamOutlined, label: '团队' },
  245. { name: 'SettingOutlined', component: SettingOutlined, label: '设置' },
  246. { name: 'FolderOutlined', component: FolderOutlined, label: '文件夹' },
  247. { name: 'FileOutlined', component: FileOutlined, label: '文件' },
  248. { name: 'FolderOpenOutlined', component: FolderOpenOutlined, label: '打开文件夹' },
  249. { name: 'DatabaseOutlined', component: DatabaseOutlined, label: '数据库' },
  250. { name: 'CloudOutlined', component: CloudOutlined, label: '云' },
  251. { name: 'ThunderboltOutlined', component: ThunderboltOutlined, label: '闪电' },
  252. { name: 'FireOutlined', component: FireOutlined, label: '火焰' },
  253. { name: 'RocketOutlined', component: RocketOutlined, label: '火箭' },
  254. { name: 'StarOutlined', component: StarOutlined, label: '星星' },
  255. { name: 'HeartOutlined', component: HeartOutlined, label: '心形' },
  256. { name: 'BellOutlined', component: BellOutlined, label: '铃铛' },
  257. { name: 'MessageOutlined', component: MessageOutlined, label: '消息' },
  258. { name: 'PhoneOutlined', component: PhoneOutlined, label: '电话' },
  259. { name: 'MailOutlined', component: MailOutlined, label: '邮件' },
  260. { name: 'CalendarOutlined', component: CalendarOutlined, label: '日历' },
  261. { name: 'ClockCircleOutlined', component: ClockCircleOutlined, label: '时钟' },
  262. { name: 'SearchOutlined', component: SearchOutlined, label: '搜索' },
  263. { name: 'PlusOutlined', component: PlusOutlined, label: '添加' },
  264. { name: 'MinusOutlined', component: MinusOutlined, label: '减少' },
  265. { name: 'DeleteOutlined', component: DeleteOutlined, label: '删除' },
  266. { name: 'EyeOutlined', component: EyeOutlined, label: '查看' },
  267. { name: 'EyeInvisibleOutlined', component: EyeInvisibleOutlined, label: '隐藏' },
  268. { name: 'DownloadOutlined', component: DownloadOutlined, label: '下载' },
  269. { name: 'UploadOutlined', component: UploadOutlined, label: '上传' },
  270. { name: 'ReloadOutlined', component: ReloadOutlined, label: '刷新' },
  271. { name: 'PlayCircleOutlined', component: PlayCircleOutlined, label: '播放' },
  272. { name: 'PauseCircleOutlined', component: PauseCircleOutlined, label: '暂停' },
  273. { name: 'StopOutlined', component: StopOutlined, label: '停止' },
  274. { name: 'CheckOutlined', component: CheckOutlined, label: '勾选' },
  275. { name: 'CloseCircleOutlined', component: CloseCircleOutlined, label: '关闭' },
  276. { name: 'WarningOutlined', component: WarningOutlined, label: '警告' },
  277. { name: 'InfoCircleOutlined', component: InfoCircleOutlined, label: '信息' },
  278. { name: 'QuestionCircleOutlined', component: QuestionCircleOutlined, label: '问号' },
  279. { name: 'LinkOutlined', component: LinkOutlined, label: '链接' },
  280. { name: 'ShareAltOutlined', component: ShareAltOutlined, label: '分享' },
  281. { name: 'CopyOutlined', component: CopyOutlined, label: '复制' },
  282. { name: 'ScissorOutlined', component: ScissorOutlined, label: '剪切' },
  283. { name: 'PrinterOutlined', component: PrinterOutlined, label: '打印' },
  284. { name: 'ShoppingCartOutlined', component: ShoppingCartOutlined, label: '购物车' },
  285. { name: 'ShoppingOutlined', component: ShoppingOutlined, label: '商店' },
  286. { name: 'GiftOutlined', component: GiftOutlined, label: '礼物' },
  287. { name: 'TrophyOutlined', component: TrophyOutlined, label: '奖杯' },
  288. { name: 'CrownOutlined', component: CrownOutlined, label: '皇冠' },
  289. { name: 'BulbOutlined', component: BulbOutlined, label: '灯泡' },
  290. { name: 'ExperimentOutlined', component: ExperimentOutlined, label: '实验' },
  291. { name: 'BugOutlined', component: BugOutlined, label: '错误' },
  292. { name: 'CodeOutlined', component: CodeOutlined, label: '代码' },
  293. { name: 'ApiOutlined', component: ApiOutlined, label: 'API' },
  294. { name: 'AppstoreOutlined', component: AppstoreOutlined, label: '应用' },
  295. { name: 'BarsOutlined', component: BarsOutlined, label: '菜单' },
  296. { name: 'MenuOutlined', component: MenuOutlined, label: '菜单' },
  297. { name: 'LayoutOutlined', component: LayoutOutlined, label: '布局' },
  298. { name: 'TableOutlined', component: TableOutlined, label: '表格' },
  299. { name: 'UnorderedListOutlined', component: UnorderedListOutlined, label: '列表' },
  300. { name: 'OrderedListOutlined', component: OrderedListOutlined, label: '有序列表' },
  301. { name: 'PictureOutlined', component: PictureOutlined, label: '图片' },
  302. { name: 'VideoCameraOutlined', component: VideoCameraOutlined, label: '视频' },
  303. { name: 'SoundOutlined', component: SoundOutlined, label: '声音' },
  304. { name: 'CustomerServiceOutlined', component: CustomerServiceOutlined, label: '客服' },
  305. { name: 'GlobalOutlined', component: GlobalOutlined, label: '全球' },
  306. { name: 'EnvironmentOutlined', component: EnvironmentOutlined, label: '位置' },
  307. { name: 'CompassOutlined', component: CompassOutlined, label: '指南针' },
  308. { name: 'CarOutlined', component: CarOutlined, label: '汽车' },
  309. { name: 'BankOutlined', component: BankOutlined, label: '银行' },
  310. { name: 'ShopOutlined', component: ShopOutlined, label: '商店' },
  311. { name: 'MedicineBoxOutlined', component: MedicineBoxOutlined, label: '医药' },
  312. { name: 'SafetyCertificateOutlined', component: SafetyCertificateOutlined, label: '证书' },
  313. { name: 'InsuranceOutlined', component: InsuranceOutlined, label: '保险' },
  314. { name: 'FileProtectOutlined', component: FileProtectOutlined, label: '保护' },
  315. { name: 'FileSyncOutlined', component: FileSyncOutlined, label: '同步' },
  316. { name: 'FileSearchOutlined', component: FileSearchOutlined, label: '搜索文件' },
  317. { name: 'FileAddOutlined', component: FileAddOutlined, label: '添加文件' },
  318. { name: 'FileExcelOutlined', component: FileExcelOutlined, label: 'Excel' },
  319. { name: 'FilePdfOutlined', component: FilePdfOutlined, label: 'PDF' },
  320. { name: 'FileWordOutlined', component: FileWordOutlined, label: 'Word' },
  321. { name: 'FileImageOutlined', component: FileImageOutlined, label: '图片文件' },
  322. { name: 'FileZipOutlined', component: FileZipOutlined, label: '压缩包' },
  323. { name: 'FolderAddOutlined', component: FolderAddOutlined, label: '添加文件夹' },
  324. { name: 'FolderViewOutlined', component: FolderViewOutlined, label: '查看文件夹' },
  325. { name: 'ProjectOutlined', component: ProjectOutlined, label: '项目' },
  326. { name: 'BuildOutlined', component: BuildOutlined, label: '构建' },
  327. { name: 'RobotOutlined', component: RobotOutlined, label: '机器人' },
  328. ];
  329. // 图标名称到组件的映射
  330. const iconNameMap: Record<string, React.ComponentType<any>> = {
  331. ToolOutlined,
  332. CheckCircleOutlined,
  333. FileTextOutlined,
  334. EditOutlined,
  335. SafetyOutlined,
  336. UnlockOutlined,
  337. LockOutlined,
  338. CheckSquareOutlined,
  339. HomeOutlined,
  340. UserOutlined,
  341. TeamOutlined,
  342. SettingOutlined,
  343. FolderOutlined,
  344. FileOutlined,
  345. FolderOpenOutlined,
  346. DatabaseOutlined,
  347. CloudOutlined,
  348. ThunderboltOutlined,
  349. FireOutlined,
  350. RocketOutlined,
  351. StarOutlined,
  352. HeartOutlined,
  353. BellOutlined,
  354. MessageOutlined,
  355. PhoneOutlined,
  356. MailOutlined,
  357. CalendarOutlined,
  358. ClockCircleOutlined,
  359. SearchOutlined,
  360. PlusOutlined,
  361. MinusOutlined,
  362. DeleteOutlined,
  363. EyeOutlined,
  364. EyeInvisibleOutlined,
  365. DownloadOutlined,
  366. UploadOutlined,
  367. ReloadOutlined,
  368. PlayCircleOutlined,
  369. PauseCircleOutlined,
  370. StopOutlined,
  371. CheckOutlined,
  372. CloseCircleOutlined,
  373. WarningOutlined,
  374. InfoCircleOutlined,
  375. QuestionCircleOutlined,
  376. LinkOutlined,
  377. ShareAltOutlined,
  378. CopyOutlined,
  379. ScissorOutlined,
  380. PrinterOutlined,
  381. ShoppingCartOutlined,
  382. ShoppingOutlined,
  383. GiftOutlined,
  384. TrophyOutlined,
  385. CrownOutlined,
  386. BulbOutlined,
  387. ExperimentOutlined,
  388. BugOutlined,
  389. CodeOutlined,
  390. ApiOutlined,
  391. AppstoreOutlined,
  392. BarsOutlined,
  393. MenuOutlined,
  394. LayoutOutlined,
  395. TableOutlined,
  396. UnorderedListOutlined,
  397. OrderedListOutlined,
  398. PictureOutlined,
  399. VideoCameraOutlined,
  400. SoundOutlined,
  401. CustomerServiceOutlined,
  402. GlobalOutlined,
  403. EnvironmentOutlined,
  404. CompassOutlined,
  405. CarOutlined,
  406. BankOutlined,
  407. ShopOutlined,
  408. MedicineBoxOutlined,
  409. SafetyCertificateOutlined,
  410. InsuranceOutlined,
  411. FileProtectOutlined,
  412. FileSyncOutlined,
  413. FileSearchOutlined,
  414. FileAddOutlined,
  415. FileExcelOutlined,
  416. FilePdfOutlined,
  417. FileWordOutlined,
  418. FileImageOutlined,
  419. FileZipOutlined,
  420. FolderAddOutlined,
  421. FolderViewOutlined,
  422. ProjectOutlined,
  423. BuildOutlined,
  424. RobotOutlined,
  425. };
  426. // 自定义节点组件
  427. function CustomNode({ data, selected, id }: any) {
  428. // 优先使用自定义图标(图片文件名),否则使用节点类型对应的图标
  429. let Icon = FileTextOutlined;
  430. let config = null;
  431. let iconImagePath: string | null = null;
  432. // 检查是否是图片文件名(如 "1000.png")
  433. if (data.icon && /^\d+\.png$/.test(data.icon)) {
  434. // 是图片文件名,获取对应的图片路径
  435. iconImagePath = getIconPathByFileName(data.icon);
  436. // 尝试找到对应的配置(保持颜色等样式)
  437. config = nodeConfigs.find(c => c.type === data.type) || nodeConfigs[0];
  438. } else if (data.icon && iconNameMap[data.icon]) {
  439. // 使用自定义图标组件
  440. Icon = iconNameMap[data.icon];
  441. // 尝试找到对应的配置(保持颜色等样式)
  442. config = nodeConfigs.find(c => c.icon === Icon);
  443. if (!config) {
  444. // 如果找不到配置,使用默认配置
  445. config = nodeConfigs.find(c => c.type === data.type) || nodeConfigs[0];
  446. }
  447. } else {
  448. // 使用节点类型对应的图标
  449. config = nodeConfigs.find(c => c.type === data.type);
  450. Icon = config?.icon || FileTextOutlined;
  451. }
  452. // 从节点ID中提取序号,或使用data中的nodeId
  453. const nodeId = data.nodeId || (id ? String(parseInt(id.split('-').pop() || '0') % 1000).padStart(3, '0') : '001');
  454. // 处理节点名称:如果超过6个字符则换行
  455. const formatNodeLabel = (text: string): string[] => {
  456. if (!text) return [];
  457. if (text.length <= 6) return [text];
  458. // 如果超过6个字符,按6个字符分割
  459. const lines: string[] = [];
  460. for (let i = 0; i < text.length; i += 6) {
  461. lines.push(text.slice(i, i + 6));
  462. }
  463. return lines;
  464. };
  465. const nodeLabel = data.label || config?.label || '';
  466. const labelLines = formatNodeLabel(nodeLabel);
  467. return (
  468. <div
  469. className={`relative px-4 py-4 rounded-lg shadow-sm border-2 w-[200px] min-w-[200px] max-w-[200px] h-auto min-h-[140px] bg-white ${
  470. selected
  471. ? 'border-blue-500 shadow-md ring-1 ring-blue-200'
  472. : 'border-gray-200 hover:border-gray-300'
  473. } transition-all`}
  474. >
  475. {/* 连接点 - 四个方向,每个方向都有唯一的 source 和 target Handle */}
  476. <Handle
  477. id="top-source"
  478. type="source"
  479. position={Position.Top}
  480. className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm"
  481. style={{ top: -6, left: '50%', transform: 'translateX(-50%)' }}
  482. isConnectable={true}
  483. />
  484. <Handle
  485. id="top-target"
  486. type="target"
  487. position={Position.Top}
  488. className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm"
  489. style={{ top: -6, left: '50%', transform: 'translateX(-50%)' }}
  490. isConnectable={true}
  491. />
  492. <Handle
  493. id="bottom-source"
  494. type="source"
  495. position={Position.Bottom}
  496. className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm"
  497. style={{ bottom: -6, left: '50%', transform: 'translateX(-50%)' }}
  498. isConnectable={true}
  499. />
  500. <Handle
  501. id="bottom-target"
  502. type="target"
  503. position={Position.Bottom}
  504. className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm"
  505. style={{ bottom: -6, left: '50%', transform: 'translateX(-50%)' }}
  506. isConnectable={true}
  507. />
  508. <Handle
  509. id="left-source"
  510. type="source"
  511. position={Position.Left}
  512. className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm"
  513. style={{ left: -6, top: '50%', transform: 'translateY(-50%)' }}
  514. isConnectable={true}
  515. />
  516. <Handle
  517. id="left-target"
  518. type="target"
  519. position={Position.Left}
  520. className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm"
  521. style={{ left: -6, top: '50%', transform: 'translateY(-50%)' }}
  522. isConnectable={true}
  523. />
  524. <Handle
  525. id="right-source"
  526. type="source"
  527. position={Position.Right}
  528. className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm"
  529. style={{ right: -6, top: '50%', transform: 'translateY(-50%)' }}
  530. isConnectable={true}
  531. />
  532. <Handle
  533. id="right-target"
  534. type="target"
  535. position={Position.Right}
  536. className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm"
  537. style={{ right: -6, top: '50%', transform: 'translateY(-50%)' }}
  538. isConnectable={true}
  539. />
  540. {/* 垂直布局:顶部图标、中间名称、底部ID */}
  541. <div className="flex flex-col items-center justify-between gap-3 h-full">
  542. {/* 顶部:图标 */}
  543. <div className={`${config?.bgColor || 'bg-gray-50'} ${config?.borderColor || 'border-gray-100'} border p-3 rounded-xl shadow-sm flex items-center justify-center flex-shrink-0`} style={{ borderRadius: '12px' }}>
  544. {iconImagePath ? (
  545. <img
  546. src={iconImagePath}
  547. alt={data.icon || '节点图标'}
  548. className="w-6 h-6 object-contain"
  549. onError={(e) => {
  550. // 如果图片加载失败,回退到默认图标
  551. console.error('节点图标加载失败:', iconImagePath);
  552. (e.target as HTMLImageElement).style.display = 'none';
  553. }}
  554. />
  555. ) : (
  556. <Icon
  557. className={`${config?.iconColor || 'text-gray-600'} text-2xl`}
  558. style={{ color: config?.iconColorCustom || undefined }}
  559. />
  560. )}
  561. </div>
  562. {/* 中间:节点名称 */}
  563. <div className="font-semibold text-sm text-gray-900 leading-tight text-center break-words w-full flex-1 flex flex-col items-center justify-center px-1">
  564. {labelLines.length > 1 ? (
  565. labelLines.map((line, index) => (
  566. <span key={index} className="block">
  567. {line}
  568. </span>
  569. ))
  570. ) : (
  571. <span>{nodeLabel}</span>
  572. )}
  573. </div>
  574. {/* 底部:ID */}
  575. <div className="text-xs text-gray-500 text-center flex-shrink-0">
  576. ID: {nodeId}
  577. </div>
  578. </div>
  579. </div>
  580. );
  581. }
  582. // 各个节点类型
  583. function CreateJobNode(props: any) {
  584. return <CustomNode {...props} />;
  585. }
  586. function ConfirmNode(props: any) {
  587. return <CustomNode {...props} />;
  588. }
  589. function ReviewNode(props: any) {
  590. return <CustomNode {...props} />;
  591. }
  592. function InputInfoNode(props: any) {
  593. return <CustomNode {...props} />;
  594. }
  595. function IsolationNode(props: any) {
  596. return <CustomNode {...props} />;
  597. }
  598. function ReleaseIsolationNode(props: any) {
  599. return <CustomNode {...props} />;
  600. }
  601. function ReturnLockNode(props: any) {
  602. return <CustomNode {...props} />;
  603. }
  604. function CompleteNode(props: any) {
  605. return <CustomNode {...props} />;
  606. }
  607. // 自定义节点类型映射
  608. const nodeTypes = {
  609. createJob: CreateJobNode,
  610. confirm: ConfirmNode,
  611. review: ReviewNode,
  612. inputInfo: InputInfoNode,
  613. isolation: IsolationNode,
  614. releaseIsolation: ReleaseIsolationNode,
  615. returnLock: ReturnLockNode,
  616. complete: CompleteNode,
  617. };
  618. // 全局变量存储删除函数(用于边组件)
  619. let globalDeleteEdgeFn: ((id: string) => void) | null = null;
  620. // 全局变量存储 edges(用于边组件获取 type)
  621. let globalEdges: Edge[] = [];
  622. // 自定义边组件 - 定义在组件外部以确保稳定引用
  623. function CustomEdgeWithDelete({
  624. id,
  625. sourceX,
  626. sourceY,
  627. targetX,
  628. targetY,
  629. selected,
  630. markerStart,
  631. style,
  632. type: propType,
  633. }: any) {
  634. // 从全局 edges 中查找对应的边来获取 type 和 fixedLength
  635. const edge = globalEdges.find(e => e.id === id);
  636. const edgeType = edge?.type || propType || 'straight';
  637. const fixedLength = (edge as any)?.data?.fixedLength || (edge as any)?.fixedLength;
  638. // 计算实际距离
  639. const dx = targetX - sourceX;
  640. const dy = targetY - sourceY;
  641. const actualDistance = Math.sqrt(dx * dx + dy * dy);
  642. // 如果设置了 fixedLength 且实际距离大于 fixedLength,则调整起点和终点
  643. let adjustedSourceX = sourceX;
  644. let adjustedSourceY = sourceY;
  645. let adjustedTargetX = targetX;
  646. let adjustedTargetY = targetY;
  647. if (fixedLength && actualDistance > fixedLength && actualDistance > 0.001) {
  648. // 计算单位方向向量
  649. const unitX = dx / actualDistance;
  650. const unitY = dy / actualDistance;
  651. // 计算需要缩短的距离(从两端各缩短一半)
  652. const shortenDistance = (actualDistance - fixedLength) / 2;
  653. // 调整起点(向目标方向移动)
  654. adjustedSourceX = sourceX + unitX * shortenDistance;
  655. adjustedSourceY = sourceY + unitY * shortenDistance;
  656. // 调整终点(向源点方向移动)
  657. adjustedTargetX = targetX - unitX * shortenDistance;
  658. adjustedTargetY = targetY - unitY * shortenDistance;
  659. }
  660. // 根据边的类型选择路径生成函数
  661. let edgePath: string;
  662. let labelX: number;
  663. let labelY: number;
  664. if (edgeType === 'smoothstep') {
  665. // 使用 smoothstep 路径(平滑步进曲线,可以拐弯)
  666. // 调整 borderRadius 参数使曲线更平滑
  667. [edgePath, labelX, labelY] = getSmoothStepPath({
  668. sourceX: adjustedSourceX,
  669. sourceY: adjustedSourceY,
  670. targetX: adjustedTargetX,
  671. targetY: adjustedTargetY,
  672. borderRadius: 15, // 圆角半径,使曲线更平滑
  673. });
  674. } else {
  675. // 使用直线路径
  676. [edgePath, labelX, labelY] = getStraightPath({
  677. sourceX: adjustedSourceX,
  678. sourceY: adjustedSourceY,
  679. targetX: adjustedTargetX,
  680. targetY: adjustedTargetY,
  681. });
  682. }
  683. console.log('边组件渲染,ID:', id, 'propType:', propType, 'edgeType:', edgeType, 'edge对象:', edge, 'sourceX:', sourceX, 'sourceY:', sourceY, 'targetX:', targetX, 'targetY:', targetY, 'selected:', selected);
  684. return (
  685. <>
  686. <BaseEdge
  687. id={id}
  688. path={edgePath}
  689. markerStart={markerStart}
  690. style={{
  691. ...style,
  692. strokeWidth: selected ? 3 : 2,
  693. stroke: selected ? '#3b82f6' : '#000000',
  694. }}
  695. />
  696. {selected && (
  697. <EdgeLabelRenderer>
  698. <div
  699. style={{
  700. position: 'absolute',
  701. left: labelX,
  702. top: labelY,
  703. transform: 'translate(-50%, -50%)',
  704. pointerEvents: 'all',
  705. zIndex: 1000,
  706. }}
  707. className="nodrag nopan"
  708. >
  709. <button
  710. onClick={(e) => {
  711. e.stopPropagation();
  712. e.preventDefault();
  713. console.log('删除按钮被点击,连线ID:', id);
  714. if (globalDeleteEdgeFn) {
  715. globalDeleteEdgeFn(id);
  716. }
  717. }}
  718. onMouseDown={(e) => {
  719. e.stopPropagation();
  720. }}
  721. className="bg-red-500 hover:bg-red-600 rounded-full p-1.5 shadow-lg transition-colors flex items-center justify-center w-8 h-8"
  722. title="删除连线"
  723. type="button"
  724. style={{ cursor: 'pointer' }}
  725. >
  726. <DeleteOutlined className="text-sm text-white" style={{ color: 'white' }} />
  727. </button>
  728. </div>
  729. </EdgeLabelRenderer>
  730. )}
  731. </>
  732. );
  733. }
  734. // 自定义边类型映射 - 定义在组件外部
  735. const edgeTypes: EdgeTypes = {
  736. straight: CustomEdgeWithDelete,
  737. default: CustomEdgeWithDelete,
  738. smoothstep: CustomEdgeWithDelete,
  739. };
  740. // 节点类型到图标分类的映射
  741. const getIconCategoryByNodeType = (nodeType: string): string => {
  742. const typeMap: Record<string, string> = {
  743. 'createJob': '开始',
  744. 'confirm': '确认',
  745. 'review': '审核',
  746. 'inputInfo': '录入',
  747. 'isolation': '能量隔离',
  748. 'releaseIsolation': '解除隔离',
  749. 'returnLock': '结束',
  750. 'complete': '结束',
  751. };
  752. return typeMap[nodeType] || '开始';
  753. };
  754. // 图标分类配置(分类名称 -> 图标文件列表)
  755. const iconCategories: Record<string, { start: number; end: number }> = {
  756. '审核': { start: 1000, end: 1011 },
  757. '开始': { start: 2000, end: 2016 },
  758. '录入': { start: 3000, end: 3028 },
  759. '确认': { start: 4000, end: 4024 },
  760. '结束': { start: 5000, end: 5018 },
  761. '能量隔离': { start: 6000, end: 6027 },
  762. '解除隔离': { start: 7000, end: 7021 },
  763. };
  764. // 使用 import.meta.glob 动态导入所有图标
  765. const iconModules = import.meta.glob('../assets/节点图标/**/*.png', { eager: true, as: 'url' });
  766. // 调试:打印加载的图标模块信息
  767. const allIconKeys = Object.keys(iconModules);
  768. console.log('ProcessDesigner: 图标模块加载情况');
  769. console.log('总数量:', allIconKeys.length);
  770. if (allIconKeys.length > 0) {
  771. console.log('前5个路径示例:', allIconKeys.slice(0, 5));
  772. // 打印一个完整的路径示例
  773. const firstKey = allIconKeys[0];
  774. console.log('第一个路径完整信息:', {
  775. key: firstKey,
  776. value: iconModules[firstKey],
  777. normalized: firstKey.replace(/\\/g, '/')
  778. });
  779. } else {
  780. console.error('ProcessDesigner: 未加载到任何图标模块!');
  781. console.error('请检查路径: ../assets/节点图标/**/*.png');
  782. console.error('提示: 如果路径正确,可能需要重启开发服务器');
  783. }
  784. // 生成图标路径列表(返回文件名和路径的映射)
  785. const generateIconPaths = (category: string): Array<{ id: string; fileName: string; path: string }> => {
  786. const config = iconCategories[category];
  787. if (!config) {
  788. console.warn(`ProcessDesigner: 分类 ${category} 没有配置`);
  789. return [];
  790. }
  791. const paths: Array<{ id: string; fileName: string; path: string }> = [];
  792. // 如果 import.meta.glob 加载成功,使用它
  793. const allKeys = Object.keys(iconModules);
  794. if (allKeys.length > 0) {
  795. // 使用 import.meta.glob 的结果
  796. for (let i = config.start; i <= config.end; i++) {
  797. const fileName = `${i}.png`;
  798. const matchingKey = allKeys.find(k => {
  799. const normalizedKey = k.replace(/\\/g, '/').toLowerCase();
  800. const normalizedCategory = category.toLowerCase();
  801. const normalizedFileName = fileName.toLowerCase();
  802. return normalizedKey.includes(normalizedCategory) && normalizedKey.endsWith(normalizedFileName);
  803. });
  804. if (matchingKey) {
  805. const iconPath = iconModules[matchingKey] as string;
  806. paths.push({ id: `${category}_${i}`, fileName: fileName, path: iconPath });
  807. }
  808. }
  809. } else {
  810. // Fallback: 使用 new URL 动态构建路径(适用于开发环境)
  811. console.warn('ProcessDesigner: import.meta.glob 未加载成功,使用 fallback 方式');
  812. for (let i = config.start; i <= config.end; i++) {
  813. try {
  814. // 使用 new URL 构建路径
  815. const fileName = `${i}.png`;
  816. const iconPath = new URL(`../assets/节点图标/${category}/${fileName}`, import.meta.url).href;
  817. paths.push({ id: `${category}_${i}`, fileName: fileName, path: iconPath });
  818. } catch (e) {
  819. console.warn(`ProcessDesigner: 无法构建图标路径 ${category}/${i}.png:`, e);
  820. }
  821. }
  822. }
  823. return paths;
  824. };
  825. // 根据文件名获取图标路径(用于节点显示)
  826. const getIconPathByFileName = (fileName: string | undefined): string | null => {
  827. if (!fileName) return null;
  828. // 如果已经是完整路径,尝试提取文件名
  829. let actualFileName = fileName;
  830. if (fileName.includes('/') || fileName.includes('\\')) {
  831. // 从路径中提取文件名
  832. const pathParts = fileName.replace(/\\/g, '/').split('/');
  833. actualFileName = pathParts[pathParts.length - 1];
  834. }
  835. // 从文件名中提取数字和分类
  836. const match = actualFileName.match(/^(\d+)\.png$/);
  837. if (!match) return null;
  838. const iconNumber = parseInt(match[1], 10);
  839. // 根据数字范围判断分类
  840. let category = '';
  841. if (iconNumber >= 1000 && iconNumber <= 1011) category = '审核';
  842. else if (iconNumber >= 2000 && iconNumber <= 2016) category = '开始';
  843. else if (iconNumber >= 3000 && iconNumber <= 3028) category = '录入';
  844. else if (iconNumber >= 4000 && iconNumber <= 4024) category = '确认';
  845. else if (iconNumber >= 5000 && iconNumber <= 5018) category = '结束';
  846. else if (iconNumber >= 6000 && iconNumber <= 6027) category = '能量隔离';
  847. else if (iconNumber >= 7000 && iconNumber <= 7021) category = '解除隔离';
  848. if (!category) return null;
  849. // 查找对应的路径
  850. const allKeys = Object.keys(iconModules);
  851. const matchingKey = allKeys.find(k => {
  852. const normalizedKey = k.replace(/\\/g, '/').toLowerCase();
  853. return normalizedKey.includes(category.toLowerCase()) && normalizedKey.endsWith(actualFileName.toLowerCase());
  854. });
  855. if (matchingKey) {
  856. return iconModules[matchingKey] as string;
  857. }
  858. return null;
  859. };
  860. // 从图标路径或文件名中提取文件名(用于保存JSON)
  861. const extractIconFileName = (icon: string | undefined): string | undefined => {
  862. if (!icon) return undefined;
  863. // 如果已经是文件名格式(如 "1000.png"),直接返回
  864. if (/^\d+\.png$/.test(icon)) {
  865. return icon;
  866. }
  867. // 如果是路径,提取文件名
  868. if (icon.includes('/') || icon.includes('\\')) {
  869. const pathParts = icon.replace(/\\/g, '/').split('/');
  870. const fileName = pathParts[pathParts.length - 1];
  871. // 如果提取的文件名符合格式,返回它
  872. if (/^\d+\.png$/.test(fileName)) {
  873. return fileName;
  874. }
  875. }
  876. // 否则返回原值(可能是其他格式的图标)
  877. return icon;
  878. };
  879. export default function ProcessDesigner() {
  880. const navigate = useNavigate();
  881. const location = useLocation();
  882. // 返回流程模板菜单的辅助函数
  883. const backToProcessTemplateMenu = () => {
  884. // 默认返回:隔离作业 -> 流程模板
  885. let menuInfo = { menu: 'isolationWork', subMenu: 'processTemplate' };
  886. try {
  887. const source = sessionStorage.getItem('processDesignerSource');
  888. if (source) {
  889. const parsed = JSON.parse(source);
  890. if (parsed?.menu && parsed?.subMenu) {
  891. menuInfo = { menu: parsed.menu, subMenu: parsed.subMenu };
  892. }
  893. sessionStorage.removeItem('processDesignerSource');
  894. }
  895. } catch (e) {
  896. console.error('解析 processDesignerSource 失败:', e);
  897. }
  898. sessionStorage.setItem('navigateToMenu', JSON.stringify(menuInfo));
  899. sessionStorage.setItem('lastActiveMenu', JSON.stringify(menuInfo));
  900. navigate('/dashboard');
  901. };
  902. const [nodes, setNodes, onNodesChangeBase] = useNodesState([]);
  903. const [edges, setEdges, onEdgesChange] = useEdgesState([]);
  904. // 保存初始状态,用于检测是否有未保存的更改
  905. const initialNodesRef = useRef<Node[]>([]);
  906. const initialEdgesRef = useRef<Edge[]>([]);
  907. const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
  908. // 是否正处于「从服务器/缓存加载或恢复」阶段,此期间不把 onNodesChange/onEdgesChange 视为用户修改
  909. const isLoadingFromServerOrRestoreRef = useRef(false);
  910. // 从URL参数获取流程ID
  911. const workflowId = React.useMemo(() => {
  912. const params = new URLSearchParams(location.search);
  913. const id = params.get('id');
  914. return id ? parseInt(id, 10) : null;
  915. }, [location.search]);
  916. const [selectedNode, setSelectedNode] = useState<Node | null>(null);
  917. const [selectedEdge, setSelectedEdge] = useState<Edge | null>(null);
  918. const [activeTabKey, setActiveTabKey] = useState<string>('info');
  919. const [rightPanelCollapsed, setRightPanelCollapsed] = useState(false);
  920. const reactFlowWrapper = useRef<HTMLDivElement>(null);
  921. const [reactFlowInstance, setReactFlowInstance] = useState<any>(null);
  922. const [zoom, setZoom] = useState(1);
  923. const [iconPickerOpen, setIconPickerOpen] = useState(false);
  924. // 简单缓存:将每个节点的配置持久化到 sessionStorage,key 按节点ID
  925. const cachePrefix = 'process_designer_node_';
  926. const [exportVisible, setExportVisible] = useState(false);
  927. const [exportContent, setExportContent] = useState('');
  928. const [importVisible, setImportVisible] = useState(false);
  929. const [importJson, setImportJson] = useState('');
  930. // 存储工作流详情(name、description等)
  931. const [workflowDetail, setWorkflowDetail] = useState<WorkflowDesignVO | null>(null);
  932. const [loadingDetail, setLoadingDetail] = useState(false);
  933. // 表单预览相关状态
  934. const [formPreviewVisible, setFormPreviewVisible] = useState(false);
  935. const [formPreviewData, setFormPreviewData] = useState<FormVO | null>(null);
  936. const [formPreviewLoading, setFormPreviewLoading] = useState(false);
  937. const [formPreviewDetailData, setFormPreviewDetailData] = useState<FormCreateData>({
  938. rule: [],
  939. option: {}
  940. });
  941. const formPreviewForm = AntdForm.useForm()[0];
  942. const defaultFormConfig = {
  943. name: '',
  944. labelPosition: 'right',
  945. formSize: 'middle',
  946. labelSuffix: '',
  947. labelWidth: 100,
  948. hideRequiredMark: false,
  949. showValidationError: true,
  950. inlineValidation: false,
  951. showSubmitButton: false,
  952. showResetButton: false,
  953. };
  954. // 角色用户列表
  955. const [drawerUsers, setDrawerUsers] = useState<UserVO[]>([]); // 负责人(jtdrawer)
  956. const [lockerUsers, setLockerUsers] = useState<UserVO[]>([]); // 上锁人(jtlocker)
  957. const [colockerUsers, setColockerUsers] = useState<UserVO[]>([]); // 共锁人(jtcolocker)
  958. // 隔离点列表
  959. const [isolationPoints, setIsolationPoints] = useState<SegregationPointVO[]>([]);
  960. // 表单列表
  961. const [formList, setFormList] = useState<FormVO[]>([]);
  962. // 隔离方式字典数据
  963. const [isolationTypeDictList, setIsolationTypeDictList] = useState<DictDataVO[]>([]);
  964. const loadNodeCache = useCallback((nodeId: string) => {
  965. try {
  966. const raw = sessionStorage.getItem(`${cachePrefix}${nodeId}`);
  967. if (!raw) return null;
  968. return JSON.parse(raw);
  969. } catch {
  970. return null;
  971. }
  972. }, []);
  973. const saveNodeCache = useCallback((nodeId: string, data: any) => {
  974. try {
  975. sessionStorage.setItem(`${cachePrefix}${nodeId}`, JSON.stringify(data));
  976. } catch {
  977. // ignore
  978. }
  979. }, []);
  980. // 流程设计JSON缓存相关函数
  981. const getCacheKey = useCallback((id: number | null) => {
  982. return id ? `process_designer_cache_${id}` : null;
  983. }, []);
  984. // 保存流程设计JSON到缓存
  985. const saveWorkflowCache = useCallback((id: number | null, nodes: Node[], edges: Edge[]) => {
  986. if (!id) return;
  987. try {
  988. // 构建导出数据(与handleSave中的逻辑相同)
  989. const adjacency: Record<string, { parentUuid: string[]; childrenUuid: string[] }> = {};
  990. const nodeIdMap = new Map<string, string>();
  991. nodes.forEach(n => {
  992. nodeIdMap.set(n.id, n.id);
  993. });
  994. nodes.forEach(n => {
  995. const uuid = nodeIdMap.get(n.id) || n.id;
  996. adjacency[uuid] = adjacency[uuid] || { parentUuid: [], childrenUuid: [] };
  997. });
  998. edges.forEach(e => {
  999. const sourceUuid = nodeIdMap.get(e.source) || e.source;
  1000. const targetUuid = nodeIdMap.get(e.target) || e.target;
  1001. if (!adjacency[sourceUuid]) adjacency[sourceUuid] = { parentUuid: [], childrenUuid: [] };
  1002. if (!adjacency[targetUuid]) adjacency[targetUuid] = { parentUuid: [], childrenUuid: [] };
  1003. adjacency[sourceUuid].parentUuid.push(targetUuid);
  1004. adjacency[targetUuid].childrenUuid.push(sourceUuid);
  1005. });
  1006. const exportData = {
  1007. nodeCount: nodes.length,
  1008. edgeCount: edges.length,
  1009. adjacency,
  1010. nodes: nodes.map(n => {
  1011. const cachedData = loadNodeCache(n.id);
  1012. const mergedData = cachedData
  1013. ? { ...n.data, ...cachedData }
  1014. : n.data;
  1015. // 将 responsible 转换为 workerUserId,并确保值是字符串
  1016. // 同时提取四个模板代码字段到顶层
  1017. const { responsible, smsTemplateCode, messageTemplateCode, emailTemplateCode, appTemplateCode, ...restData } = mergedData;
  1018. const processedData = {
  1019. ...restData,
  1020. workerUserId: responsible !== undefined && responsible !== null && responsible !== ''
  1021. ? String(responsible)
  1022. : '',
  1023. };
  1024. const nodeObj: any = {
  1025. uuid: n.id,
  1026. type: n.type,
  1027. position: n.position,
  1028. nodeName: processedData.label || '',
  1029. nodeIcon: processedData.icon || processedData.type || n.type || '',
  1030. smsTemplateCode: smsTemplateCode || 'false',
  1031. messageTemplateCode: messageTemplateCode || 'false',
  1032. emailTemplateCode: emailTemplateCode || 'false',
  1033. appTemplateCode: appTemplateCode || 'false',
  1034. data: processedData,
  1035. };
  1036. return nodeObj;
  1037. }),
  1038. edges: edges.map(e => {
  1039. return {
  1040. id: e.id,
  1041. source: nodeIdMap.get(e.source) || e.source,
  1042. target: nodeIdMap.get(e.target) || e.target,
  1043. sourceHandle: e.sourceHandle,
  1044. targetHandle: e.targetHandle,
  1045. type: e.type || 'straight',
  1046. };
  1047. }),
  1048. };
  1049. const content = JSON.stringify(exportData, null, 2);
  1050. const cacheKey = getCacheKey(id);
  1051. if (cacheKey) {
  1052. localStorage.setItem(cacheKey, content);
  1053. console.log('流程设计已保存到缓存:', cacheKey);
  1054. }
  1055. } catch (error) {
  1056. console.error('保存流程设计缓存失败:', error);
  1057. }
  1058. }, [loadNodeCache, getCacheKey]);
  1059. // 读取流程设计JSON缓存
  1060. const loadWorkflowCache = useCallback((id: number | null): string | null => {
  1061. if (!id) return null;
  1062. const cacheKey = getCacheKey(id);
  1063. if (!cacheKey) return null;
  1064. try {
  1065. return localStorage.getItem(cacheKey);
  1066. } catch {
  1067. return null;
  1068. }
  1069. }, [getCacheKey]);
  1070. // 清除流程设计JSON缓存
  1071. const clearWorkflowCache = useCallback((id: number | null) => {
  1072. if (!id) return;
  1073. const cacheKey = getCacheKey(id);
  1074. if (cacheKey) {
  1075. localStorage.removeItem(cacheKey);
  1076. console.log('流程设计缓存已清除:', cacheKey);
  1077. }
  1078. }, [getCacheKey]);
  1079. // 清除当前流程的所有节点缓存
  1080. const clearCurrentWorkflowNodeCache = useCallback(() => {
  1081. if (!nodes || nodes.length === 0) return;
  1082. try {
  1083. nodes.forEach((node) => {
  1084. const nodeCacheKey = `${cachePrefix}${node.id}`;
  1085. sessionStorage.removeItem(nodeCacheKey);
  1086. });
  1087. console.log('当前流程的节点缓存已清除');
  1088. } catch (error) {
  1089. console.error('清除节点缓存失败:', error);
  1090. }
  1091. }, [nodes]);
  1092. // 比较两个 JSON 字符串内容是否相同(忽略格式差异)
  1093. const compareJsonContent = useCallback((json1: string | null, json2: string | null): boolean => {
  1094. if (!json1 || !json2) return false;
  1095. try {
  1096. const obj1 = JSON.parse(json1);
  1097. const obj2 = JSON.parse(json2);
  1098. // 使用 JSON.stringify 比较,确保顺序一致
  1099. return JSON.stringify(obj1) === JSON.stringify(obj2);
  1100. } catch {
  1101. // 如果解析失败,直接比较字符串
  1102. return json1.trim() === json2.trim();
  1103. }
  1104. }, []);
  1105. // 比较当前状态和初始状态,检测是否有未保存的更改
  1106. const checkUnsavedChanges = useCallback(() => {
  1107. // 比较节点数量
  1108. if (nodes.length !== initialNodesRef.current.length) {
  1109. return true;
  1110. }
  1111. // 比较边数量
  1112. if (edges.length !== initialEdgesRef.current.length) {
  1113. return true;
  1114. }
  1115. // 创建初始节点的映射,方便通过ID查找
  1116. const initialNodesMap = new Map(initialNodesRef.current.map(n => [n.id, n]));
  1117. const initialEdgesMap = new Map(initialEdgesRef.current.map(e => [e.id, e]));
  1118. // 深度比较节点(包括位置、数据等)
  1119. const nodesChanged = nodes.some((node) => {
  1120. const initialNode = initialNodesMap.get(node.id);
  1121. if (!initialNode) return true; // 新节点
  1122. // 比较基本属性
  1123. if (node.type !== initialNode.type ||
  1124. node.position.x !== initialNode.position.x ||
  1125. node.position.y !== initialNode.position.y) {
  1126. return true;
  1127. }
  1128. // 比较节点数据(包括缓存数据)
  1129. const cachedData = loadNodeCache(node.id);
  1130. const mergedData = cachedData ? { ...node.data, ...cachedData } : node.data;
  1131. // 对于初始节点,我们只比较初始数据,不考虑初始时的缓存
  1132. const initialMergedData = initialNode.data;
  1133. return JSON.stringify(mergedData) !== JSON.stringify(initialMergedData);
  1134. });
  1135. if (nodesChanged) {
  1136. return true;
  1137. }
  1138. // 检查是否有节点被删除
  1139. const hasDeletedNode = initialNodesRef.current.some(initialNode =>
  1140. !nodes.find(n => n.id === initialNode.id)
  1141. );
  1142. if (hasDeletedNode) {
  1143. return true;
  1144. }
  1145. // 深度比较边
  1146. const edgesChanged = edges.some((edge) => {
  1147. const initialEdge = initialEdgesMap.get(edge.id);
  1148. if (!initialEdge) return true; // 新边
  1149. return edge.source !== initialEdge.source ||
  1150. edge.target !== initialEdge.target ||
  1151. edge.sourceHandle !== initialEdge.sourceHandle ||
  1152. edge.targetHandle !== initialEdge.targetHandle ||
  1153. edge.type !== initialEdge.type;
  1154. });
  1155. if (edgesChanged) {
  1156. return true;
  1157. }
  1158. // 检查是否有边被删除
  1159. const hasDeletedEdge = initialEdgesRef.current.some(initialEdge =>
  1160. !edges.find(e => e.id === initialEdge.id)
  1161. );
  1162. return hasDeletedEdge;
  1163. }, [nodes, edges, loadNodeCache]);
  1164. // 调试:监听 edges 变化
  1165. useEffect(() => {
  1166. console.log('Edges 状态更新:', edges.length, edges);
  1167. }, [edges]);
  1168. // 仅在用户真正修改过(hasUnsavedChanges)时写入缓存,供异常退出后恢复使用
  1169. useEffect(() => {
  1170. if (workflowId && (nodes.length > 0 || edges.length > 0) && hasUnsavedChanges) {
  1171. saveWorkflowCache(workflowId, nodes, edges);
  1172. }
  1173. }, [nodes, edges, workflowId, hasUnsavedChanges, saveWorkflowCache]);
  1174. // 获取隔离方式字典数据
  1175. const getIsolationMethodDictList = async () => {
  1176. try {
  1177. const response = await dictDataApi.getDictDataPage({
  1178. pageNo: 1,
  1179. pageSize: -1,
  1180. dictType: 'isolation_method',
  1181. });
  1182. const data = (response as any)?.data || response;
  1183. const list = data?.list || [];
  1184. setIsolationTypeDictList(list);
  1185. } catch (error: any) {
  1186. console.error('获取隔离方式字典失败:', error);
  1187. setIsolationTypeDictList([]);
  1188. }
  1189. };
  1190. // 加载角色用户列表和隔离点列表
  1191. useEffect(() => {
  1192. const loadRoleUsers = async () => {
  1193. try {
  1194. // 并行加载三种角色的用户
  1195. const [drawerRes, lockerRes, colockerRes] = await Promise.all([
  1196. userApi.getRoleUser('jtdrawer'),
  1197. userApi.getRoleUser('jtlocker'),
  1198. userApi.getRoleUser('jtcolocker'),
  1199. ]);
  1200. setDrawerUsers(drawerRes || []);
  1201. setLockerUsers(lockerRes || []);
  1202. setColockerUsers(colockerRes || []);
  1203. } catch (error) {
  1204. console.error('加载角色用户失败:', error);
  1205. }
  1206. };
  1207. const loadIsolationPoints = async () => {
  1208. try {
  1209. const res = await segregationPointApi.getIsIsolationPointPage({ pageNo: 1, pageSize: -1 });
  1210. setIsolationPoints(res.list || []);
  1211. } catch (error) {
  1212. console.error('加载隔离点列表失败:', error);
  1213. }
  1214. };
  1215. const loadFormList = async () => {
  1216. try {
  1217. const res = await getFormPage({ pageNo: 1, pageSize: -1, status: 0 }); // 只获取开启状态(status=0)的表单
  1218. setFormList(res.list || []);
  1219. } catch (error) {
  1220. console.error('加载表单列表失败:', error);
  1221. }
  1222. };
  1223. loadRoleUsers();
  1224. loadIsolationPoints();
  1225. loadFormList();
  1226. getIsolationMethodDictList();
  1227. }, []);
  1228. // 节点配置状态
  1229. const [nodeConfig, setNodeConfig] = useState({
  1230. nodeName: '',
  1231. nodeIcon: '',
  1232. responsible: '',
  1233. remark: '',
  1234. formId: '',
  1235. isolationType: '', // 隔离方式
  1236. isolationPoints: [] as string[], // 隔离点选择(多选)
  1237. isolationNode: [] as string[], // 隔离节点(多选)
  1238. isolationNodeUuid: '', // 选择的隔离/方案节点ID(用于解除隔离节点)
  1239. lockPerson: '', // 上锁人
  1240. coLockPersons: [] as string[], // 共锁人(多选)
  1241. notificationMethods: {
  1242. sms: false,
  1243. message: false,
  1244. email: false,
  1245. app: false,
  1246. },
  1247. notificationPerson: '',
  1248. notificationTime: '',
  1249. smsTemplateCode: 'false',
  1250. messageTemplateCode: 'false',
  1251. emailTemplateCode: 'false',
  1252. appTemplateCode: 'false',
  1253. });
  1254. // 实时缓存并更新节点配置,避免切换节点后丢失未保存的输入
  1255. // 若当前为隔离/方案节点,将修改内容自动同步到所有已关联的解除隔离节点,避免设计人员忘记手动维护
  1256. useEffect(() => {
  1257. if (selectedNode) {
  1258. const { isolationMethod, ...restData } = selectedNode.data || {};
  1259. // 将 responsible 同时保存为 workerUserId,确保数据一致性
  1260. const responsibleValue = nodeConfig.responsible || '';
  1261. const updatedData = {
  1262. ...restData,
  1263. label: nodeConfig.nodeName,
  1264. icon: nodeConfig.nodeIcon,
  1265. responsible: responsibleValue,
  1266. workerUserId: responsibleValue, // 同时保存 workerUserId 字段
  1267. remark: nodeConfig.remark,
  1268. formId: nodeConfig.formId,
  1269. isolationType: nodeConfig.isolationType,
  1270. isolationPoints: nodeConfig.isolationPoints,
  1271. isolationNode: nodeConfig.isolationNode,
  1272. isolationNodeUuid: nodeConfig.isolationNodeUuid,
  1273. lockPerson: nodeConfig.lockPerson,
  1274. coLockPersons: nodeConfig.coLockPersons,
  1275. notificationMethods: nodeConfig.notificationMethods,
  1276. notificationPerson: nodeConfig.notificationPerson,
  1277. notificationTime: nodeConfig.notificationTime,
  1278. smsTemplateCode: nodeConfig.smsTemplateCode,
  1279. messageTemplateCode: nodeConfig.messageTemplateCode,
  1280. emailTemplateCode: nodeConfig.emailTemplateCode,
  1281. appTemplateCode: nodeConfig.appTemplateCode,
  1282. nodeName: nodeConfig.nodeName,
  1283. nodeIcon: nodeConfig.nodeIcon,
  1284. };
  1285. // 缓存配置
  1286. saveNodeCache(selectedNode.id, updatedData);
  1287. const isIsolationNode = selectedNode.data?.type === 'isolation';
  1288. const isolationSyncPayload = isIsolationNode
  1289. ? {
  1290. isolationType: nodeConfig.isolationType,
  1291. isolationPoints: nodeConfig.isolationPoints,
  1292. lockPerson: nodeConfig.lockPerson,
  1293. coLockPersons: nodeConfig.coLockPersons,
  1294. responsible: responsibleValue,
  1295. workerUserId: responsibleValue,
  1296. }
  1297. : null;
  1298. // 实时更新节点显示;若当前为隔离/方案节点,则把修改内容同步到所有已关联的解除隔离节点
  1299. setNodes((nds) =>
  1300. nds.map((node) => {
  1301. if (node.id === selectedNode.id) {
  1302. return { ...node, data: updatedData };
  1303. }
  1304. if (
  1305. isolationSyncPayload &&
  1306. node.data?.type === 'releaseIsolation' &&
  1307. String(node.data?.isolationNodeUuid) === String(selectedNode.id)
  1308. ) {
  1309. const releaseData = {
  1310. ...node.data,
  1311. ...isolationSyncPayload,
  1312. };
  1313. saveNodeCache(node.id, releaseData);
  1314. return { ...node, data: releaseData };
  1315. }
  1316. return node;
  1317. })
  1318. );
  1319. }
  1320. }, [nodeConfig, selectedNode, saveNodeCache, setNodes]);
  1321. // 历史记录(用于撤销/重做)
  1322. const [history, setHistory] = useState<{ nodes: Node[]; edges: Edge[] }[]>([]);
  1323. const [historyIndex, setHistoryIndex] = useState(-1);
  1324. // 加载 JSON 数据到画布(可复用的函数)
  1325. const loadJsonToCanvas = useCallback((jsonString: string, updateHistory: boolean = false) => {
  1326. if (updateHistory) {
  1327. isLoadingFromServerOrRestoreRef.current = true;
  1328. }
  1329. try {
  1330. const data = JSON.parse(jsonString);
  1331. if (!data.nodes || !Array.isArray(data.nodes)) {
  1332. message.error('JSON 格式错误:缺少 nodes 数组');
  1333. return false;
  1334. }
  1335. if (!data.edges || !Array.isArray(data.edges)) {
  1336. message.error('JSON 格式错误:缺少 edges 数组');
  1337. return false;
  1338. }
  1339. // 创建uuid到id的映射(导入时uuid改回id)
  1340. const uuidToIdMap = new Map<string, string>();
  1341. data.nodes.forEach((node: any) => {
  1342. // 兼容处理:支持uuid字段或id字段
  1343. const nodeId = node.uuid || node.id;
  1344. uuidToIdMap.set(nodeId, nodeId); // 值保持不变
  1345. });
  1346. // 还原节点,确保 data 包含所有配置字段
  1347. const importedNodes = data.nodes.map((node: any) => {
  1348. const nodeData = node.data || {};
  1349. // 兼容处理:支持uuid字段或id字段
  1350. const nodeId = node.uuid || node.id;
  1351. // 如果顶层有 nodeName 和 nodeIcon,将它们还原到 data 中
  1352. const topLevelLabel = node.nodeName || '';
  1353. const topLevelIcon = node.nodeIcon || '';
  1354. // 从顶层读取四个模板代码字段(与 uuid 同级)
  1355. const topLevelSmsTemplateCode = node.smsTemplateCode;
  1356. const topLevelMessageTemplateCode = node.messageTemplateCode;
  1357. const topLevelEmailTemplateCode = node.emailTemplateCode;
  1358. const topLevelAppTemplateCode = node.appTemplateCode;
  1359. // 兼容处理:优先使用 workerUserId,如果没有则使用 responsible(向后兼容),并确保值是字符串
  1360. const workerUserIdValue = nodeData.workerUserId !== undefined && nodeData.workerUserId !== null && nodeData.workerUserId !== ''
  1361. ? String(nodeData.workerUserId)
  1362. : (nodeData.responsible !== undefined && nodeData.responsible !== null && nodeData.responsible !== ''
  1363. ? String(nodeData.responsible)
  1364. : '');
  1365. // 确保所有字段都存在,使用默认值填充缺失的字段
  1366. const completeData = {
  1367. label: topLevelLabel || nodeData.label || '', // 优先使用顶层的 nodeName
  1368. type: nodeData.type || node.type,
  1369. nodeId: nodeData.nodeId || '',
  1370. icon: topLevelIcon || nodeData.icon || nodeData.type || node.type, // 优先使用顶层的 nodeIcon
  1371. responsible: workerUserIdValue, // 内部仍使用 responsible 字段名,但值来自 workerUserId 或 responsible
  1372. workerUserId: workerUserIdValue, // 同时保存 workerUserId 字段,确保数据一致性
  1373. remark: nodeData.remark || '',
  1374. formId: nodeData.formId || nodeData.submitForm ? String(nodeData.formId || nodeData.submitForm) : '', // 兼容旧的 submitForm 字段,确保是字符串类型
  1375. isolationType: nodeData.isolationType || '',
  1376. isolationPoints: Array.isArray(nodeData.isolationPoints) ? nodeData.isolationPoints : [],
  1377. isolationNode: Array.isArray(nodeData.isolationNode) ? nodeData.isolationNode : [],
  1378. isolationNodeUuid: nodeData.isolationNodeUuid || '',
  1379. lockPerson: nodeData.lockPerson || '',
  1380. coLockPersons: Array.isArray(nodeData.coLockPersons) ? nodeData.coLockPersons : [],
  1381. notificationMethods: nodeData.notificationMethods || {
  1382. sms: false,
  1383. message: false,
  1384. email: false,
  1385. app: false,
  1386. },
  1387. notificationPerson: nodeData.notificationPerson || '',
  1388. notificationTime: nodeData.notificationTime || '',
  1389. smsTemplateCode: topLevelSmsTemplateCode !== undefined ? topLevelSmsTemplateCode : (nodeData.smsTemplateCode || 'false'),
  1390. messageTemplateCode: topLevelMessageTemplateCode !== undefined ? topLevelMessageTemplateCode : (nodeData.messageTemplateCode || 'false'),
  1391. emailTemplateCode: topLevelEmailTemplateCode !== undefined ? topLevelEmailTemplateCode : (nodeData.emailTemplateCode || 'false'),
  1392. appTemplateCode: topLevelAppTemplateCode !== undefined ? topLevelAppTemplateCode : (nodeData.appTemplateCode || 'false'),
  1393. ...nodeData, // 保留其他可能的字段
  1394. };
  1395. return {
  1396. id: nodeId, // 使用uuid或id作为id
  1397. type: node.type,
  1398. position: node.position || { x: 0, y: 0 },
  1399. data: completeData,
  1400. };
  1401. });
  1402. // 还原连接线(source和target需要从uuid映射回id)
  1403. const importedEdges = data.edges.map((edge: any) => {
  1404. // 兼容处理:如果source/target是uuid,映射回id;如果是id,直接使用
  1405. const sourceId = uuidToIdMap.get(edge.source) || edge.source;
  1406. const targetId = uuidToIdMap.get(edge.target) || edge.target;
  1407. return {
  1408. id: edge.id,
  1409. source: sourceId,
  1410. target: targetId,
  1411. sourceHandle: edge.sourceHandle,
  1412. targetHandle: edge.targetHandle,
  1413. type: edge.type || 'straight',
  1414. animated: false,
  1415. style: { strokeWidth: 2, stroke: '#000000' },
  1416. markerStart: {
  1417. type: 'arrowclosed',
  1418. color: '#000000',
  1419. },
  1420. data: {
  1421. fixedLength: edge.data?.fixedLength || 150, // 保留导入的 fixedLength 或设置为默认值 150
  1422. },
  1423. };
  1424. });
  1425. // 更新节点和边
  1426. setNodes(importedNodes);
  1427. setEdges(importedEdges);
  1428. // 缓存每个节点的配置(确保缓存包含完整数据)
  1429. importedNodes.forEach((node: Node) => {
  1430. if (node.data) {
  1431. saveNodeCache(node.id, node.data);
  1432. }
  1433. });
  1434. // 如果需要更新历史记录(初始化加载时)
  1435. if (updateHistory) {
  1436. setHistory([{ nodes: importedNodes, edges: importedEdges }]);
  1437. setHistoryIndex(0);
  1438. // 更新初始状态,用于检测未保存的更改
  1439. initialNodesRef.current = JSON.parse(JSON.stringify(importedNodes));
  1440. initialEdgesRef.current = JSON.parse(JSON.stringify(importedEdges));
  1441. setHasUnsavedChanges(false);
  1442. // 短时内不把后续的 onNodesChange/onEdgesChange 视为用户修改,避免保存后再次进入仍弹「未保存」框
  1443. setTimeout(() => {
  1444. isLoadingFromServerOrRestoreRef.current = false;
  1445. }, 200);
  1446. }
  1447. return true;
  1448. } catch (error: any) {
  1449. if (updateHistory) {
  1450. isLoadingFromServerOrRestoreRef.current = false;
  1451. }
  1452. console.error('加载 JSON 失败:', error);
  1453. message.error('JSON 格式错误:' + (error?.message || '解析失败'));
  1454. return false;
  1455. }
  1456. }, [saveNodeCache, setNodes, setEdges, setHistory, setHistoryIndex]);
  1457. // 进入页面时获取工作流详情
  1458. useEffect(() => {
  1459. const fetchWorkflowDetail = async () => {
  1460. if (!workflowId) {
  1461. return;
  1462. }
  1463. setLoadingDetail(true);
  1464. try {
  1465. const detail = await workflowDesignApi.selectWorkflowDesignById(workflowId);
  1466. setWorkflowDetail(detail);
  1467. // 检查是否有缓存
  1468. const cachedContent = loadWorkflowCache(workflowId);
  1469. const hasApiContent = detail.content && detail.content.trim().length > 0;
  1470. const hasCacheContent = cachedContent && cachedContent.trim().length > 0;
  1471. // 如果接口有内容且缓存也有内容,需要比较内容是否相同
  1472. if (hasApiContent && hasCacheContent) {
  1473. // 比较缓存内容和接口内容是否相同
  1474. const isContentSame = compareJsonContent(cachedContent, detail.content);
  1475. if (isContentSame) {
  1476. // 内容相同,说明用户没有修改,直接使用接口内容,不提示
  1477. loadJsonToCanvas(detail.content, true);
  1478. } else {
  1479. // 内容不同,说明用户有修改,弹出确认框
  1480. Modal.confirm({
  1481. title: '检测到未保存的设计',
  1482. content: '检测到您有未保存的设计稿(可能为异常退出),需要恢复继续设计吗?',
  1483. okText: '恢复',
  1484. cancelText: '不恢复',
  1485. onOk: () => {
  1486. // 使用缓存内容恢复;恢复后视为「未保存」以便点击返回时提示保存或放弃
  1487. loadJsonToCanvas(cachedContent, true);
  1488. setHasUnsavedChanges(true);
  1489. },
  1490. onCancel: () => {
  1491. // 使用接口内容,并清除缓存避免下次再提示
  1492. clearWorkflowCache(workflowId);
  1493. loadJsonToCanvas(detail.content, true);
  1494. },
  1495. });
  1496. }
  1497. } else if (hasCacheContent) {
  1498. // 只有缓存,直接使用缓存
  1499. loadJsonToCanvas(cachedContent, true);
  1500. } else if (hasApiContent) {
  1501. // 只有接口内容,使用接口内容
  1502. loadJsonToCanvas(detail.content, true);
  1503. }
  1504. } catch (error: any) {
  1505. console.error('获取工作流详情失败:', error);
  1506. message.error(error?.message || '获取工作流详情失败');
  1507. } finally {
  1508. setLoadingDetail(false);
  1509. }
  1510. };
  1511. fetchWorkflowDetail();
  1512. }, [workflowId, loadJsonToCanvas, loadWorkflowCache, compareJsonContent, clearWorkflowCache]);
  1513. // 拖拽处理
  1514. const onDragStart = (event: React.DragEvent, nodeType: string) => {
  1515. event.dataTransfer.setData('application/reactflow', nodeType);
  1516. event.dataTransfer.effectAllowed = 'move';
  1517. };
  1518. // 验证连接是否有效(允许所有连接)
  1519. const isValidConnection = useCallback((connection: Connection) => {
  1520. // 允许从任意方向连接到任意方向
  1521. if (!connection.source || !connection.target) {
  1522. return false;
  1523. }
  1524. // 不允许节点连接到自身
  1525. if (connection.source === connection.target) {
  1526. return false;
  1527. }
  1528. return true;
  1529. }, []);
  1530. // 修正 Handle 类型的辅助函数
  1531. const fixHandleType = (handleId: string | null | undefined, isSource: boolean): string | undefined => {
  1532. if (!handleId) return undefined;
  1533. // 如果 sourceHandle 是 target 类型,转换为对应的 source Handle
  1534. if (isSource && handleId.endsWith('-target')) {
  1535. const position = handleId.replace('-target', '');
  1536. return `${position}-source`;
  1537. }
  1538. // 如果 targetHandle 是 source 类型,转换为对应的 target Handle
  1539. if (!isSource && handleId.endsWith('-source')) {
  1540. const position = handleId.replace('-source', '');
  1541. return `${position}-target`;
  1542. }
  1543. return handleId;
  1544. };
  1545. // 连接处理
  1546. const onConnect = useCallback(
  1547. (params: Connection) => {
  1548. // 确保连接参数包含 sourceHandle 和 targetHandle
  1549. console.log('连接参数:', params);
  1550. if (!params.source || !params.target) {
  1551. console.warn('连接参数无效:', params);
  1552. return;
  1553. }
  1554. if (params.source === params.target) {
  1555. console.warn('不能连接节点到自身');
  1556. return;
  1557. }
  1558. // 修正 Handle 类型
  1559. let sourceHandle = fixHandleType(params.sourceHandle, true);
  1560. let targetHandle = fixHandleType(params.targetHandle, false);
  1561. if (sourceHandle !== params.sourceHandle || targetHandle !== params.targetHandle) {
  1562. console.log('修正 Handle 类型:', {
  1563. sourceHandle: `${params.sourceHandle} -> ${sourceHandle}`,
  1564. targetHandle: `${params.targetHandle} -> ${targetHandle}`
  1565. });
  1566. }
  1567. setEdges((eds) => {
  1568. // 检查是否已存在相同的连接(只检查 source 和 target)
  1569. const existingEdgeIndex = eds.findIndex(
  1570. (edge) =>
  1571. edge.source === params.source &&
  1572. edge.target === params.target
  1573. );
  1574. if (existingEdgeIndex !== -1) {
  1575. console.log('连接已存在,更新连接点:', {
  1576. old: { sourceHandle: eds[existingEdgeIndex].sourceHandle, targetHandle: eds[existingEdgeIndex].targetHandle },
  1577. new: { sourceHandle, targetHandle }
  1578. });
  1579. // 更新现有连接的 Handle
  1580. const updatedEdges = [...eds];
  1581. updatedEdges[existingEdgeIndex] = {
  1582. ...updatedEdges[existingEdgeIndex],
  1583. sourceHandle,
  1584. targetHandle,
  1585. type: 'straight', // 统一使用直线
  1586. markerStart: {
  1587. type: 'arrowclosed',
  1588. color: '#000000',
  1589. },
  1590. data: {
  1591. fixedLength: 150, // 设置连接线固定长度为150
  1592. },
  1593. };
  1594. // 保存历史
  1595. const newHistory = history.slice(0, historyIndex + 1);
  1596. newHistory.push({ nodes: [...nodes], edges: [...updatedEdges] });
  1597. setHistory(newHistory);
  1598. setHistoryIndex(newHistory.length - 1);
  1599. return updatedEdges;
  1600. }
  1601. // 统一使用直线连接
  1602. const edgeId = `edge-${params.source}-${sourceHandle || 'default'}-${params.target}-${targetHandle || 'default'}-${Date.now()}`;
  1603. const newEdge: Edge = {
  1604. id: edgeId,
  1605. source: params.source!,
  1606. target: params.target!,
  1607. sourceHandle: sourceHandle || undefined,
  1608. targetHandle: targetHandle || undefined,
  1609. animated: false,
  1610. style: { strokeWidth: 2, stroke: '#000000' },
  1611. type: 'straight', // 统一使用直线
  1612. markerStart: {
  1613. type: 'arrowclosed',
  1614. color: '#000000',
  1615. },
  1616. data: {
  1617. fixedLength: 150, // 设置连接线固定长度为150
  1618. },
  1619. };
  1620. console.log('创建新边,类型:', newEdge.type, '边对象:', newEdge);
  1621. // 直接添加到数组,不使用 addEdge(因为 addEdge 可能会过滤掉某些连接)
  1622. const newEdges = [...eds, newEdge];
  1623. console.log('新连接已添加:', newEdge);
  1624. console.log('新边的类型:', newEdge.type);
  1625. console.log('当前所有连接数量:', newEdges.length);
  1626. // 保存历史
  1627. const newHistory = history.slice(0, historyIndex + 1);
  1628. newHistory.push({ nodes: [...nodes], edges: [...newEdges] });
  1629. setHistory(newHistory);
  1630. setHistoryIndex(newHistory.length - 1);
  1631. return newEdges;
  1632. });
  1633. setHasUnsavedChanges(true);
  1634. },
  1635. [setEdges, history, historyIndex, nodes]
  1636. );
  1637. // 更新连接线(拖拽边的端点重新连接)
  1638. const onEdgeUpdate = useCallback(
  1639. (oldEdge: Edge, newConnection: Connection) => {
  1640. console.log('更新连接线:', { oldEdge, newConnection });
  1641. if (!newConnection.source || !newConnection.target) {
  1642. console.warn('新连接参数无效');
  1643. return;
  1644. }
  1645. if (newConnection.source === newConnection.target) {
  1646. console.warn('不能连接节点到自身');
  1647. return;
  1648. }
  1649. // 修正 Handle 类型
  1650. let sourceHandle = fixHandleType(newConnection.sourceHandle, true);
  1651. let targetHandle = fixHandleType(newConnection.targetHandle, false);
  1652. setEdges((eds) => {
  1653. // 找到要更新的边
  1654. const edgeIndex = eds.findIndex((edge) => edge.id === oldEdge.id);
  1655. if (edgeIndex === -1) {
  1656. console.warn('未找到要更新的边');
  1657. return eds;
  1658. }
  1659. // 检查新连接是否与现有连接冲突(除了当前要更新的边)
  1660. const conflictingEdge = eds.find(
  1661. (edge, index) =>
  1662. index !== edgeIndex &&
  1663. edge.source === newConnection.source &&
  1664. edge.target === newConnection.target
  1665. );
  1666. if (conflictingEdge) {
  1667. console.warn('连接已存在,无法更新');
  1668. return eds;
  1669. }
  1670. // 更新边的连接点
  1671. const updatedEdges = [...eds];
  1672. const existingEdge = updatedEdges[edgeIndex];
  1673. updatedEdges[edgeIndex] = {
  1674. ...existingEdge,
  1675. source: newConnection.source!,
  1676. target: newConnection.target!,
  1677. sourceHandle: sourceHandle || undefined,
  1678. targetHandle: targetHandle || undefined,
  1679. data: {
  1680. ...(existingEdge.data || {}),
  1681. fixedLength: (existingEdge.data as any)?.fixedLength || 150, // 保留或设置 fixedLength
  1682. },
  1683. };
  1684. console.log('连接线已更新:', updatedEdges[edgeIndex]);
  1685. // 保存历史
  1686. const newHistory = history.slice(0, historyIndex + 1);
  1687. newHistory.push({ nodes: [...nodes], edges: [...updatedEdges] });
  1688. setHistory(newHistory);
  1689. setHistoryIndex(newHistory.length - 1);
  1690. return updatedEdges;
  1691. });
  1692. setHasUnsavedChanges(true);
  1693. },
  1694. [history, historyIndex, nodes]
  1695. );
  1696. // 节点点击处理
  1697. const onNodeClick = useCallback((event: React.MouseEvent, node: Node) => {
  1698. setSelectedNode(node);
  1699. setSelectedEdge(null); // 点击节点时取消连线选择
  1700. setActiveTabKey('info'); // 切换节点时重置到第一个tab
  1701. // 清除所有边的选中状态
  1702. setEdges((eds) =>
  1703. eds.map((e) => ({
  1704. ...e,
  1705. selected: false,
  1706. }))
  1707. );
  1708. // 加载节点配置
  1709. const nodeData = node.data || {};
  1710. const cache = loadNodeCache(node.id);
  1711. const source = cache || nodeData;
  1712. const config = nodeConfigs.find(c => c.type === source.type);
  1713. // 如果是解除隔离节点且已选择了隔离节点,则从隔离节点获取配置
  1714. let isolationType = source.isolationType || '';
  1715. let isolationPointsData = source.isolationPoints || [];
  1716. let lockPerson = source.lockPerson || '';
  1717. let coLockPersons = source.coLockPersons || [];
  1718. // 优先使用 workerUserId,如果没有则使用 responsible(向后兼容),并确保值是字符串
  1719. let responsible = (source.workerUserId !== undefined && source.workerUserId !== null && source.workerUserId !== '')
  1720. ? String(source.workerUserId)
  1721. : (source.responsible !== undefined && source.responsible !== null && source.responsible !== '')
  1722. ? String(source.responsible)
  1723. : '';
  1724. if (source.type === 'releaseIsolation' && source.isolationNodeUuid) {
  1725. const targetNode = nodes.find(n => n.id === source.isolationNodeUuid && n.data?.type === 'isolation');
  1726. if (targetNode) {
  1727. // 优先从缓存中读取,缓存中没有则从 node.data 读取
  1728. const targetCache = loadNodeCache(targetNode.id);
  1729. const targetSource = targetCache || targetNode.data || {};
  1730. isolationType = targetSource.isolationType || '';
  1731. isolationPointsData = targetSource.isolationPoints || [];
  1732. lockPerson = targetSource.lockPerson || '';
  1733. coLockPersons = targetSource.coLockPersons || [];
  1734. // 优先使用 workerUserId,如果没有则使用 responsible(向后兼容),并确保值是字符串
  1735. responsible = (targetSource.workerUserId !== undefined && targetSource.workerUserId !== null && targetSource.workerUserId !== '')
  1736. ? String(targetSource.workerUserId)
  1737. : (targetSource.responsible !== undefined && targetSource.responsible !== null && targetSource.responsible !== '')
  1738. ? String(targetSource.responsible)
  1739. : '';
  1740. }
  1741. }
  1742. setNodeConfig({
  1743. nodeName: source.label || config?.label || '',
  1744. nodeIcon: source.icon || source.type || '',
  1745. responsible: responsible,
  1746. remark: source.remark || '',
  1747. formId: source.formId || source.submitForm ? String(source.formId || source.submitForm) : '', // 兼容旧的 submitForm 字段,确保是字符串类型
  1748. isolationType: isolationType,
  1749. isolationPoints: isolationPointsData,
  1750. isolationNode: Array.isArray(source.isolationNode) ? source.isolationNode : (source.isolationNode ? [source.isolationNode] : []),
  1751. isolationNodeUuid: source.isolationNodeUuid || '',
  1752. lockPerson: lockPerson,
  1753. coLockPersons: coLockPersons,
  1754. notificationMethods: source.notificationMethods || {
  1755. sms: false,
  1756. message: false,
  1757. email: false,
  1758. app: false,
  1759. },
  1760. notificationPerson: source.notificationPerson || '',
  1761. notificationTime: source.notificationTime || '',
  1762. smsTemplateCode: source.smsTemplateCode || 'false',
  1763. messageTemplateCode: source.messageTemplateCode || 'false',
  1764. emailTemplateCode: source.emailTemplateCode || 'false',
  1765. appTemplateCode: source.appTemplateCode || 'false',
  1766. });
  1767. // 如果是创建作业节点,且当前tab是"提交表单",自动切换到"节点信息"tab
  1768. if (node.data?.type === 'createJob' && activeTabKey === 'form') {
  1769. setActiveTabKey('info');
  1770. }
  1771. }, [loadNodeCache, nodes, setEdges]);
  1772. // 画布点击处理(取消选择)
  1773. const onPaneClick = useCallback(() => {
  1774. setSelectedNode(null);
  1775. setSelectedEdge(null);
  1776. // 清除所有边的选中状态
  1777. setEdges((eds) =>
  1778. eds.map((e) => ({
  1779. ...e,
  1780. selected: false,
  1781. }))
  1782. );
  1783. }, [setEdges]);
  1784. // 连线点击处理
  1785. const onEdgeClick = useCallback((event: React.MouseEvent, edge: Edge) => {
  1786. event.stopPropagation();
  1787. console.log('连线被点击:', edge.id, '当前type:', edge.type);
  1788. setSelectedEdge(edge);
  1789. setSelectedNode(null); // 点击连线时取消节点选择
  1790. // 更新边的选中状态,并确保类型是 straight
  1791. setEdges((eds) => {
  1792. const updatedEdges = eds.map((e) => ({
  1793. ...e,
  1794. type: edge.type || 'straight', // 保持原有类型
  1795. selected: e.id === edge.id,
  1796. }));
  1797. const clickedEdge = updatedEdges.find(e => e.id === edge.id);
  1798. console.log('更新后的边状态:', clickedEdge?.selected, 'type:', clickedEdge?.type);
  1799. return updatedEdges;
  1800. });
  1801. }, [setEdges]);
  1802. // 删除连线
  1803. const handleDeleteEdge = useCallback((edgeId: string) => {
  1804. console.log('删除连线:', edgeId);
  1805. setEdges((eds) => {
  1806. const newEdges = eds.filter((edge) => edge.id !== edgeId);
  1807. console.log('删除后的连线数量:', newEdges.length);
  1808. // 保存历史
  1809. const newHistory = history.slice(0, historyIndex + 1);
  1810. newHistory.push({ nodes: [...nodes], edges: [...newEdges] });
  1811. setHistory(newHistory);
  1812. setHistoryIndex(newHistory.length - 1);
  1813. return newEdges;
  1814. });
  1815. setSelectedEdge(null);
  1816. setHasUnsavedChanges(true);
  1817. }, [history, historyIndex, nodes]);
  1818. // 更新全局删除函数和 edges
  1819. useEffect(() => {
  1820. globalDeleteEdgeFn = handleDeleteEdge;
  1821. globalEdges = edges;
  1822. return () => {
  1823. globalDeleteEdgeFn = null;
  1824. globalEdges = [];
  1825. };
  1826. }, [handleDeleteEdge, edges]);
  1827. // 确保所有边都使用直线类型
  1828. useEffect(() => {
  1829. setEdges((eds) => {
  1830. const updatedEdges = eds.map(edge => {
  1831. // 如果类型不是 straight,统一改为 straight
  1832. if (edge.type !== 'straight') {
  1833. return {
  1834. ...edge,
  1835. type: 'straight',
  1836. markerStart: {
  1837. type: 'arrowclosed',
  1838. color: '#000000',
  1839. },
  1840. };
  1841. }
  1842. // 确保所有边都有箭头标记
  1843. if (!edge.markerStart) {
  1844. return {
  1845. ...edge,
  1846. markerStart: {
  1847. type: 'arrowclosed',
  1848. color: '#000000',
  1849. },
  1850. };
  1851. }
  1852. return edge;
  1853. });
  1854. // 检查是否有更新
  1855. const hasUpdate = updatedEdges.some((e, index) => e.type !== eds[index]?.type);
  1856. if (hasUpdate) {
  1857. return updatedEdges;
  1858. }
  1859. return eds;
  1860. });
  1861. }, [setEdges]);
  1862. // 删除节点
  1863. const handleDeleteNode = useCallback((nodeId?: string) => {
  1864. const targetNodeId = nodeId || selectedNode?.id;
  1865. if (!targetNodeId) return;
  1866. Modal.confirm({
  1867. title: '确认删除',
  1868. content: '确定要删除这个节点吗?删除后无法恢复。',
  1869. okText: '确定删除',
  1870. okType: 'danger',
  1871. cancelText: '取消',
  1872. onOk: () => {
  1873. setNodes((nds) => {
  1874. const newNodes = nds.filter((node) => node.id !== targetNodeId);
  1875. // 同时删除相关的边
  1876. setEdges((eds) => {
  1877. const newEdges = eds.filter(
  1878. (edge) => edge.source !== targetNodeId && edge.target !== targetNodeId
  1879. );
  1880. // 保存历史
  1881. const newHistory = history.slice(0, historyIndex + 1);
  1882. newHistory.push({ nodes: [...newNodes], edges: [...newEdges] });
  1883. setHistory(newHistory);
  1884. setHistoryIndex(newHistory.length - 1);
  1885. return newEdges;
  1886. });
  1887. // 保存历史
  1888. const newHistory = history.slice(0, historyIndex + 1);
  1889. newHistory.push({ nodes: [...newNodes], edges: [...edges] });
  1890. setHistory(newHistory);
  1891. setHistoryIndex(newHistory.length - 1);
  1892. return newNodes;
  1893. });
  1894. if (selectedNode?.id === targetNodeId) {
  1895. setSelectedNode(null);
  1896. }
  1897. setHasUnsavedChanges(true);
  1898. toast.success('节点已删除');
  1899. },
  1900. });
  1901. }, [selectedNode, setNodes, setEdges, history, historyIndex, edges]);
  1902. // 键盘事件处理(Delete 键删除节点)
  1903. useEffect(() => {
  1904. const handleKeyDown = (event: KeyboardEvent) => {
  1905. // 如果焦点在输入框、文本域或可编辑元素中,不触发删除节点
  1906. const target = event.target as HTMLElement;
  1907. const isInputElement =
  1908. target.tagName === 'INPUT' ||
  1909. target.tagName === 'TEXTAREA' ||
  1910. target.isContentEditable ||
  1911. target.closest('input') ||
  1912. target.closest('textarea') ||
  1913. target.closest('[contenteditable="true"]');
  1914. if (isInputElement) {
  1915. return; // 在输入框中,不处理删除节点
  1916. }
  1917. if ((event.key === 'Delete' || event.key === 'Backspace') && selectedNode) {
  1918. event.preventDefault();
  1919. handleDeleteNode();
  1920. }
  1921. };
  1922. window.addEventListener('keydown', handleKeyDown);
  1923. return () => {
  1924. window.removeEventListener('keydown', handleKeyDown);
  1925. };
  1926. }, [selectedNode, handleDeleteNode]);
  1927. // 右键菜单处理
  1928. const onNodeContextMenu = useCallback(
  1929. (event: React.MouseEvent, node: Node) => {
  1930. event.preventDefault();
  1931. setSelectedNode(node);
  1932. },
  1933. []
  1934. );
  1935. // 撤销
  1936. const handleUndo = useCallback(() => {
  1937. if (historyIndex > 0) {
  1938. const prevState = history[historyIndex - 1];
  1939. setNodes(prevState.nodes);
  1940. setEdges(prevState.edges);
  1941. setHistoryIndex(historyIndex - 1);
  1942. setHasUnsavedChanges(true);
  1943. }
  1944. }, [history, historyIndex, setNodes, setEdges]);
  1945. // 重做
  1946. const handleRedo = useCallback(() => {
  1947. if (historyIndex < history.length - 1) {
  1948. const nextState = history[historyIndex + 1];
  1949. setNodes(nextState.nodes);
  1950. setEdges(nextState.edges);
  1951. setHistoryIndex(historyIndex + 1);
  1952. setHasUnsavedChanges(true);
  1953. }
  1954. }, [history, historyIndex, setNodes, setEdges]);
  1955. // 网格步长,与画布 snapGrid 一致
  1956. const GRID_SIZE = 16;
  1957. // 水平线磁吸阈值:放置时与已有节点 Y 相差在此范围内则对齐到该节点水平线
  1958. const HORIZONTAL_SNAP_THRESHOLD = 40;
  1959. // 拖放处理:支持网格对齐 + 与前一个节点同一水平线磁吸
  1960. const onDrop = useCallback(
  1961. (event: React.DragEvent) => {
  1962. event.preventDefault();
  1963. const type = event.dataTransfer.getData('application/reactflow');
  1964. if (!type || !reactFlowInstance) return;
  1965. let position = reactFlowInstance.screenToFlowPosition({
  1966. x: event.clientX,
  1967. y: event.clientY,
  1968. });
  1969. const config = nodeConfigs.find(c => c.type === type);
  1970. const timestamp = Date.now();
  1971. const nodeId = `${type}-${timestamp}`;
  1972. setNodes((nds) => {
  1973. // 网格对齐
  1974. position = {
  1975. x: Math.round(position.x / GRID_SIZE) * GRID_SIZE,
  1976. y: Math.round(position.y / GRID_SIZE) * GRID_SIZE,
  1977. };
  1978. // 水平线磁吸:若有已有节点,且当前 Y 与最后一个节点 Y 接近,则对齐到同一水平线
  1979. if (nds.length > 0) {
  1980. const lastNode = nds[nds.length - 1];
  1981. const refY = lastNode.position.y;
  1982. if (Math.abs(position.y - refY) <= HORIZONTAL_SNAP_THRESHOLD) {
  1983. position.y = refY;
  1984. }
  1985. }
  1986. const nodeNumber = nds.length + 1;
  1987. const newNode: Node = {
  1988. id: nodeId,
  1989. type,
  1990. position: { ...position },
  1991. data: {
  1992. label: config?.label || type,
  1993. type,
  1994. nodeId: String(nodeNumber).padStart(3, '0'),
  1995. smsTemplateCode: 'false',
  1996. messageTemplateCode: 'false',
  1997. emailTemplateCode: 'false',
  1998. appTemplateCode: 'false',
  1999. },
  2000. };
  2001. const newNodes = nds.concat(newNode);
  2002. // 保存历史
  2003. const newHistory = history.slice(0, historyIndex + 1);
  2004. newHistory.push({ nodes: [...newNodes], edges: [...edges] });
  2005. setHistory(newHistory);
  2006. setHistoryIndex(newHistory.length - 1);
  2007. return newNodes;
  2008. });
  2009. setHasUnsavedChanges(true);
  2010. },
  2011. [reactFlowInstance, setNodes, history, historyIndex, edges]
  2012. );
  2013. const onDragOver = useCallback((event: React.DragEvent) => {
  2014. event.preventDefault();
  2015. event.dataTransfer.dropEffect = 'move';
  2016. }, []);
  2017. // 拖拽节点时的水平线磁吸:与画布上其他节点 Y 接近时对齐到同一水平线;节点变更时标记为已修改
  2018. const onNodesChange = useCallback(
  2019. (changes: NodeChange[]) => {
  2020. const modified = changes.map((ch) => {
  2021. if (ch.type === 'position' && ch.position != null && ch.dragging) {
  2022. const otherNodes = nodes.filter((n) => n.id !== ch.id);
  2023. let newY = ch.position!.y;
  2024. for (const n of otherNodes) {
  2025. if (Math.abs(ch.position!.y - n.position.y) <= HORIZONTAL_SNAP_THRESHOLD) {
  2026. newY = n.position.y;
  2027. break;
  2028. }
  2029. }
  2030. return { ...ch, position: { ...ch.position!, y: newY } };
  2031. }
  2032. return ch;
  2033. });
  2034. if (!isLoadingFromServerOrRestoreRef.current) {
  2035. setHasUnsavedChanges(true);
  2036. }
  2037. onNodesChangeBase(modified);
  2038. },
  2039. [nodes, onNodesChangeBase]
  2040. );
  2041. // 边变更时标记为已修改(仅用户操作会触发 onEdgesChange);加载/恢复阶段不标记
  2042. const onEdgesChangeWithDirty = useCallback(
  2043. (changes: Parameters<typeof onEdgesChange>[0]) => {
  2044. if (!isLoadingFromServerOrRestoreRef.current) {
  2045. setHasUnsavedChanges(true);
  2046. }
  2047. onEdgesChange(changes);
  2048. },
  2049. [onEdgesChange]
  2050. );
  2051. // 更新节点配置
  2052. const updateNodeConfig = useCallback(() => {
  2053. if (!selectedNode) return;
  2054. setNodes((nds) => {
  2055. const updatedNodes = nds.map((node) => {
  2056. if (node.id === selectedNode.id) {
  2057. const { isolationMethod, ...restNodeData } = node.data || {};
  2058. const updatedNode = {
  2059. ...node,
  2060. data: {
  2061. ...restNodeData,
  2062. label: nodeConfig.nodeName,
  2063. type: nodeConfig.nodeIcon || node.data?.type, // 更新节点类型(图标)
  2064. icon: nodeConfig.nodeIcon, // 保存图标名称
  2065. responsible: nodeConfig.responsible,
  2066. remark: nodeConfig.remark,
  2067. formId: nodeConfig.formId ? String(nodeConfig.formId) : '',
  2068. isolationType: nodeConfig.isolationType,
  2069. isolationPoints: nodeConfig.isolationPoints,
  2070. isolationNode: nodeConfig.isolationNode,
  2071. isolationNodeUuid: nodeConfig.isolationNodeUuid,
  2072. lockPerson: nodeConfig.lockPerson,
  2073. coLockPersons: nodeConfig.coLockPersons,
  2074. notificationMethods: nodeConfig.notificationMethods,
  2075. notificationPerson: nodeConfig.notificationPerson,
  2076. notificationTime: nodeConfig.notificationTime,
  2077. smsTemplateCode: nodeConfig.smsTemplateCode,
  2078. messageTemplateCode: nodeConfig.messageTemplateCode,
  2079. emailTemplateCode: nodeConfig.emailTemplateCode,
  2080. appTemplateCode: nodeConfig.appTemplateCode,
  2081. },
  2082. };
  2083. // 缓存当前节点配置
  2084. saveNodeCache(node.id, updatedNode.data);
  2085. // 更新 selectedNode 的引用,以便顶部标题能显示最新值
  2086. setSelectedNode(updatedNode);
  2087. return updatedNode;
  2088. }
  2089. return node;
  2090. });
  2091. // 保存历史
  2092. const newHistory = history.slice(0, historyIndex + 1);
  2093. newHistory.push({ nodes: [...updatedNodes], edges: [...edges] });
  2094. setHistory(newHistory);
  2095. setHistoryIndex(newHistory.length - 1);
  2096. return updatedNodes;
  2097. });
  2098. setHasUnsavedChanges(true);
  2099. toast.success('节点配置已保存');
  2100. }, [selectedNode, nodeConfig, setNodes, setSelectedNode, history, historyIndex, edges]);
  2101. // 保存流程(不跳转页面)
  2102. const handleSaveWithoutNavigate = async () => {
  2103. try {
  2104. // 构建导出数据(与导出JSON逻辑相同)
  2105. const adjacency: Record<string, { parentUuid: string[]; childrenUuid: string[] }> = {};
  2106. const nodeIdMap = new Map<string, string>();
  2107. nodes.forEach(n => {
  2108. nodeIdMap.set(n.id, n.id);
  2109. });
  2110. nodes.forEach(n => {
  2111. const uuid = nodeIdMap.get(n.id) || n.id;
  2112. adjacency[uuid] = adjacency[uuid] || { parentUuid: [], childrenUuid: [] };
  2113. });
  2114. edges.forEach(e => {
  2115. const sourceUuid = nodeIdMap.get(e.source) || e.source;
  2116. const targetUuid = nodeIdMap.get(e.target) || e.target;
  2117. if (!adjacency[sourceUuid]) adjacency[sourceUuid] = { parentUuid: [], childrenUuid: [] };
  2118. if (!adjacency[targetUuid]) adjacency[targetUuid] = { parentUuid: [], childrenUuid: [] };
  2119. // 调换字段:原来childrenUuid的地方现在用parentUuid,原来parentUuid的地方现在用childrenUuid
  2120. adjacency[sourceUuid].parentUuid.push(targetUuid);
  2121. adjacency[targetUuid].childrenUuid.push(sourceUuid);
  2122. });
  2123. const exportData = {
  2124. nodeCount: nodes.length,
  2125. edgeCount: edges.length,
  2126. adjacency,
  2127. nodes: nodes.map(n => {
  2128. const cachedData = loadNodeCache(n.id);
  2129. const mergedData = cachedData
  2130. ? { ...n.data, ...cachedData }
  2131. : n.data;
  2132. // 将 responsible 转换为 workerUserId,并确保值是字符串
  2133. // 同时提取四个模板代码字段到顶层
  2134. const { responsible, smsTemplateCode, messageTemplateCode, emailTemplateCode, appTemplateCode, ...restData } = mergedData;
  2135. const processedData = {
  2136. ...restData,
  2137. workerUserId: responsible !== undefined && responsible !== null && responsible !== ''
  2138. ? String(responsible)
  2139. : '',
  2140. };
  2141. const nodeObj: any = {
  2142. uuid: n.id,
  2143. type: n.type,
  2144. position: n.position,
  2145. nodeName: processedData.label || '',
  2146. nodeIcon: processedData.icon || processedData.type || n.type || '',
  2147. smsTemplateCode: smsTemplateCode || 'false',
  2148. messageTemplateCode: messageTemplateCode || 'false',
  2149. emailTemplateCode: emailTemplateCode || 'false',
  2150. appTemplateCode: appTemplateCode || 'false',
  2151. data: processedData,
  2152. };
  2153. return nodeObj;
  2154. }),
  2155. edges: edges.map(e => {
  2156. return {
  2157. id: e.id,
  2158. source: nodeIdMap.get(e.source) || e.source,
  2159. target: nodeIdMap.get(e.target) || e.target,
  2160. sourceHandle: e.sourceHandle,
  2161. targetHandle: e.targetHandle,
  2162. type: e.type,
  2163. };
  2164. }),
  2165. };
  2166. // 将整个 JSON 作为 content 字段传递
  2167. const content = JSON.stringify(exportData, null, 2);
  2168. // 必须有流程ID才能保存(因为新建时只传了name,设计完成后必须调用更新接口)
  2169. if (!workflowId) {
  2170. message.warning('无法保存:缺少流程ID,请先创建流程');
  2171. return;
  2172. }
  2173. // 从暂存的详情中获取 name 和 description
  2174. const name = workflowDetail?.name || '';
  2175. const description = workflowDetail?.description || '';
  2176. // 确保所有节点都包含模板代码字段,检查并修复缺失的字段
  2177. exportData.nodes.forEach((node: any) => {
  2178. if (node.smsTemplateCode === undefined) node.smsTemplateCode = 'false';
  2179. if (node.messageTemplateCode === undefined) node.messageTemplateCode = 'false';
  2180. if (node.emailTemplateCode === undefined) node.emailTemplateCode = 'false';
  2181. if (node.appTemplateCode === undefined) node.appTemplateCode = 'false';
  2182. });
  2183. // 重新序列化 content,确保所有节点都包含模板代码字段
  2184. const finalContent = JSON.stringify(exportData, null, 2);
  2185. // 调用更新接口,传递 id、content、name、description
  2186. await workflowDesignApi.updateWorkflowDesign({
  2187. id: workflowId,
  2188. content: finalContent,
  2189. name: name,
  2190. description: description,
  2191. });
  2192. message.success('流程保存成功');
  2193. // 保存成功后清除缓存
  2194. clearWorkflowCache(workflowId);
  2195. // 更新初始状态
  2196. initialNodesRef.current = JSON.parse(JSON.stringify(nodes));
  2197. initialEdgesRef.current = JSON.parse(JSON.stringify(edges));
  2198. setHasUnsavedChanges(false);
  2199. } catch (error: any) {
  2200. console.error('保存流程失败:', error);
  2201. message.error(error?.message || '流程保存失败');
  2202. }
  2203. };
  2204. // 保存流程
  2205. const handleSave = async () => {
  2206. try {
  2207. // 构建导出数据(与导出JSON逻辑相同)
  2208. const adjacency: Record<string, { parentUuid: string[]; childrenUuid: string[] }> = {};
  2209. const nodeIdMap = new Map<string, string>();
  2210. nodes.forEach(n => {
  2211. nodeIdMap.set(n.id, n.id);
  2212. });
  2213. nodes.forEach(n => {
  2214. const uuid = nodeIdMap.get(n.id) || n.id;
  2215. adjacency[uuid] = adjacency[uuid] || { parentUuid: [], childrenUuid: [] };
  2216. });
  2217. edges.forEach(e => {
  2218. const sourceUuid = nodeIdMap.get(e.source) || e.source;
  2219. const targetUuid = nodeIdMap.get(e.target) || e.target;
  2220. if (!adjacency[sourceUuid]) adjacency[sourceUuid] = { parentUuid: [], childrenUuid: [] };
  2221. if (!adjacency[targetUuid]) adjacency[targetUuid] = { parentUuid: [], childrenUuid: [] };
  2222. // 调换字段:原来childrenUuid的地方现在用parentUuid,原来parentUuid的地方现在用childrenUuid
  2223. adjacency[sourceUuid].parentUuid.push(targetUuid);
  2224. adjacency[targetUuid].childrenUuid.push(sourceUuid);
  2225. });
  2226. const exportData = {
  2227. nodeCount: nodes.length,
  2228. edgeCount: edges.length,
  2229. adjacency,
  2230. nodes: nodes.map(n => {
  2231. const cachedData = loadNodeCache(n.id);
  2232. const mergedData = cachedData
  2233. ? { ...n.data, ...cachedData }
  2234. : n.data;
  2235. // 将 responsible 转换为 workerUserId,并确保值是字符串
  2236. // 同时提取四个模板代码字段到顶层
  2237. const { responsible, smsTemplateCode, messageTemplateCode, emailTemplateCode, appTemplateCode, ...restData } = mergedData;
  2238. const processedData = {
  2239. ...restData,
  2240. workerUserId: responsible !== undefined && responsible !== null && responsible !== ''
  2241. ? String(responsible)
  2242. : '',
  2243. };
  2244. const nodeObj: any = {
  2245. uuid: n.id,
  2246. type: n.type,
  2247. position: n.position,
  2248. nodeName: processedData.label || '',
  2249. nodeIcon: processedData.icon || processedData.type || n.type || '',
  2250. smsTemplateCode: smsTemplateCode || 'false',
  2251. messageTemplateCode: messageTemplateCode || 'false',
  2252. emailTemplateCode: emailTemplateCode || 'false',
  2253. appTemplateCode: appTemplateCode || 'false',
  2254. data: processedData,
  2255. };
  2256. return nodeObj;
  2257. }),
  2258. edges: edges.map(e => {
  2259. return {
  2260. id: e.id,
  2261. source: nodeIdMap.get(e.source) || e.source,
  2262. target: nodeIdMap.get(e.target) || e.target,
  2263. sourceHandle: e.sourceHandle,
  2264. targetHandle: e.targetHandle,
  2265. type: e.type,
  2266. };
  2267. }),
  2268. };
  2269. // 将整个 JSON 作为 content 字段传递
  2270. const content = JSON.stringify(exportData, null, 2);
  2271. // 必须有流程ID才能保存(因为新建时只传了name,设计完成后必须调用更新接口)
  2272. if (!workflowId) {
  2273. message.warning('无法保存:缺少流程ID,请先创建流程');
  2274. return;
  2275. }
  2276. // 从暂存的详情中获取 name 和 description
  2277. const name = workflowDetail?.name || '';
  2278. const description = workflowDetail?.description || '';
  2279. // 确保所有节点都包含模板代码字段,检查并修复缺失的字段
  2280. exportData.nodes.forEach((node: any) => {
  2281. if (node.smsTemplateCode === undefined) node.smsTemplateCode = 'false';
  2282. if (node.messageTemplateCode === undefined) node.messageTemplateCode = 'false';
  2283. if (node.emailTemplateCode === undefined) node.emailTemplateCode = 'false';
  2284. if (node.appTemplateCode === undefined) node.appTemplateCode = 'false';
  2285. });
  2286. // 重新序列化 content,确保所有节点都包含模板代码字段
  2287. const finalContent = JSON.stringify(exportData, null, 2);
  2288. // 调用更新接口,传递 id、content、name、description
  2289. await workflowDesignApi.updateWorkflowDesign({
  2290. id: workflowId,
  2291. content: finalContent,
  2292. name: name,
  2293. description: description,
  2294. });
  2295. // 保存成功后清除缓存
  2296. clearWorkflowCache(workflowId);
  2297. // 更新初始状态
  2298. initialNodesRef.current = JSON.parse(JSON.stringify(nodes));
  2299. initialEdgesRef.current = JSON.parse(JSON.stringify(edges));
  2300. setHasUnsavedChanges(false);
  2301. // 显示成功提示
  2302. message.success('流程保存成功');
  2303. // 等待提示显示后再跳转(给用户看到成功提示的时间)
  2304. setTimeout(() => {
  2305. // 保存成功后返回流程设计列表页面(与返回按钮逻辑相同)
  2306. backToProcessTemplateMenu();
  2307. }, 500);
  2308. } catch (error: any) {
  2309. console.error('保存流程失败:', error);
  2310. message.error(error?.message || '流程保存失败');
  2311. }
  2312. };
  2313. // 返回(仅在有未保存修改时弹框:保存 或 放弃并返回)
  2314. const handleBack = () => {
  2315. if (hasUnsavedChanges) {
  2316. Modal.confirm({
  2317. title: '未保存的更改',
  2318. content: '检测到您有未保存的更改,请先保存后再返回,或放弃更改并返回。',
  2319. okText: '保存',
  2320. cancelText: '放弃并返回',
  2321. maskClosable: false,
  2322. closable: false,
  2323. onOk: async () => {
  2324. // 调用保存函数
  2325. try {
  2326. // 构建导出数据(与handleSave中的逻辑相同)
  2327. const adjacency: Record<string, { parentUuid: string[]; childrenUuid: string[] }> = {};
  2328. const nodeIdMap = new Map<string, string>();
  2329. nodes.forEach(n => {
  2330. nodeIdMap.set(n.id, n.id);
  2331. });
  2332. nodes.forEach(n => {
  2333. const uuid = nodeIdMap.get(n.id) || n.id;
  2334. adjacency[uuid] = adjacency[uuid] || { parentUuid: [], childrenUuid: [] };
  2335. });
  2336. edges.forEach(e => {
  2337. const sourceUuid = nodeIdMap.get(e.source) || e.source;
  2338. const targetUuid = nodeIdMap.get(e.target) || e.target;
  2339. if (!adjacency[sourceUuid]) adjacency[sourceUuid] = { parentUuid: [], childrenUuid: [] };
  2340. if (!adjacency[targetUuid]) adjacency[targetUuid] = { parentUuid: [], childrenUuid: [] };
  2341. adjacency[sourceUuid].parentUuid.push(targetUuid);
  2342. adjacency[targetUuid].childrenUuid.push(sourceUuid);
  2343. });
  2344. const exportData = {
  2345. nodeCount: nodes.length,
  2346. edgeCount: edges.length,
  2347. adjacency,
  2348. nodes: nodes.map(n => {
  2349. const cachedData = loadNodeCache(n.id);
  2350. const mergedData = cachedData
  2351. ? { ...n.data, ...cachedData }
  2352. : n.data;
  2353. // 将 responsible 转换为 workerUserId,并确保值是字符串
  2354. // 同时提取四个模板代码字段到顶层
  2355. const { responsible, smsTemplateCode, messageTemplateCode, emailTemplateCode, appTemplateCode, ...restData } = mergedData;
  2356. const processedData = {
  2357. ...restData,
  2358. workerUserId: responsible !== undefined && responsible !== null && responsible !== ''
  2359. ? String(responsible)
  2360. : '',
  2361. };
  2362. const nodeObj: any = {
  2363. uuid: n.id,
  2364. type: n.type,
  2365. position: n.position,
  2366. nodeName: processedData.label || '',
  2367. nodeIcon: processedData.icon || processedData.type || n.type || '',
  2368. smsTemplateCode: smsTemplateCode || 'false',
  2369. messageTemplateCode: messageTemplateCode || 'false',
  2370. emailTemplateCode: emailTemplateCode || 'false',
  2371. appTemplateCode: appTemplateCode || 'false',
  2372. data: processedData,
  2373. };
  2374. return nodeObj;
  2375. }),
  2376. edges: edges.map(e => {
  2377. return {
  2378. id: e.id,
  2379. source: nodeIdMap.get(e.source) || e.source,
  2380. target: nodeIdMap.get(e.target) || e.target,
  2381. sourceHandle: e.sourceHandle,
  2382. targetHandle: e.targetHandle,
  2383. type: e.type,
  2384. };
  2385. }),
  2386. };
  2387. // 确保所有节点都包含模板代码字段,检查并修复缺失的字段
  2388. exportData.nodes.forEach((node: any) => {
  2389. if (node.smsTemplateCode === undefined) node.smsTemplateCode = 'false';
  2390. if (node.messageTemplateCode === undefined) node.messageTemplateCode = 'false';
  2391. if (node.emailTemplateCode === undefined) node.emailTemplateCode = 'false';
  2392. if (node.appTemplateCode === undefined) node.appTemplateCode = 'false';
  2393. });
  2394. const content = JSON.stringify(exportData, null, 2);
  2395. if (!workflowId) {
  2396. message.warning('无法保存:缺少流程ID,请先创建流程');
  2397. return;
  2398. }
  2399. const name = workflowDetail?.name || '';
  2400. const description = workflowDetail?.description || '';
  2401. await workflowDesignApi.updateWorkflowDesign({
  2402. id: workflowId,
  2403. content: content,
  2404. name: name,
  2405. description: description,
  2406. });
  2407. clearWorkflowCache(workflowId);
  2408. message.success('流程保存成功');
  2409. // 更新初始状态
  2410. initialNodesRef.current = JSON.parse(JSON.stringify(nodes));
  2411. initialEdgesRef.current = JSON.parse(JSON.stringify(edges));
  2412. setHasUnsavedChanges(false);
  2413. // 保存成功后返回
  2414. setTimeout(() => {
  2415. backToProcessTemplateMenu();
  2416. }, 500);
  2417. } catch (error: any) {
  2418. console.error('保存流程失败:', error);
  2419. message.error(error?.message || '流程保存失败');
  2420. }
  2421. },
  2422. onCancel: () => {
  2423. // 放弃并返回:清除缓存后跳转到流程模板列表
  2424. clearWorkflowCache(workflowId);
  2425. backToProcessTemplateMenu();
  2426. },
  2427. });
  2428. } else {
  2429. // 没有未保存的更改,直接返回
  2430. backToProcessTemplateMenu();
  2431. }
  2432. };
  2433. // 缩放控制
  2434. const handleZoomIn = () => {
  2435. if (reactFlowInstance) {
  2436. reactFlowInstance.zoomIn();
  2437. }
  2438. };
  2439. const handleZoomOut = () => {
  2440. if (reactFlowInstance) {
  2441. reactFlowInstance.zoomOut();
  2442. }
  2443. };
  2444. // 导出流程 JSON
  2445. const handleExportJson = useCallback(() => {
  2446. // 创建节点ID到UUID的映射(导出时id改为uuid,但值保持不变)
  2447. const nodeIdMap = new Map<string, string>();
  2448. nodes.forEach(n => {
  2449. nodeIdMap.set(n.id, n.id); // 值保持不变,只是字段名改为uuid
  2450. });
  2451. // adjacency使用uuid作为key
  2452. const adjacency: Record<string, { parentUuid: string[]; childrenUuid: string[] }> = {};
  2453. nodes.forEach(n => {
  2454. const uuid = nodeIdMap.get(n.id) || n.id;
  2455. adjacency[uuid] = adjacency[uuid] || { parentUuid: [], childrenUuid: [] };
  2456. });
  2457. edges.forEach(e => {
  2458. const sourceUuid = nodeIdMap.get(e.source) || e.source;
  2459. const targetUuid = nodeIdMap.get(e.target) || e.target;
  2460. if (!adjacency[sourceUuid]) adjacency[sourceUuid] = { parentUuid: [], childrenUuid: [] };
  2461. if (!adjacency[targetUuid]) adjacency[targetUuid] = { parentUuid: [], childrenUuid: [] };
  2462. // 调换字段:原来childrenUuid的地方现在用parentUuid,原来parentUuid的地方现在用childrenUuid
  2463. adjacency[sourceUuid].parentUuid.push(targetUuid);
  2464. adjacency[targetUuid].childrenUuid.push(sourceUuid);
  2465. });
  2466. const exportData = {
  2467. nodeCount: nodes.length,
  2468. edgeCount: edges.length,
  2469. adjacency,
  2470. nodes: nodes.map(n => {
  2471. // 从缓存中读取最新配置
  2472. const cachedData = loadNodeCache(n.id);
  2473. // 合并缓存数据和节点数据,缓存数据优先级更高
  2474. const mergedData = cachedData
  2475. ? { ...n.data, ...cachedData }
  2476. : n.data;
  2477. // 将 responsible 转换为 workerUserId,并确保值是字符串
  2478. // 同时提取四个模板代码字段到顶层
  2479. const { responsible, smsTemplateCode, messageTemplateCode, emailTemplateCode, appTemplateCode, ...restData } = mergedData;
  2480. const processedData = {
  2481. ...restData,
  2482. workerUserId: responsible !== undefined && responsible !== null && responsible !== ''
  2483. ? String(responsible)
  2484. : '',
  2485. };
  2486. // 导出时将id字段改为uuid,但值保持不变
  2487. // 将 nodeName 和 nodeIcon 从 data 中提取到顶层,方便后端识别,但保留 data 中的原始字段
  2488. const nodeObj: any = {
  2489. uuid: n.id, // 字段名改为uuid,值保持不变
  2490. type: n.type,
  2491. position: n.position,
  2492. nodeName: processedData.label || '', // 从 data.label 提取到顶层作为 nodeName
  2493. nodeIcon: processedData.icon || processedData.type || n.type || '', // 从 data.icon 或 data.type 提取到顶层作为 nodeIcon
  2494. smsTemplateCode: smsTemplateCode || 'false',
  2495. messageTemplateCode: messageTemplateCode || 'false',
  2496. emailTemplateCode: emailTemplateCode || 'false',
  2497. appTemplateCode: appTemplateCode || 'false',
  2498. data: processedData, // 保留完整的 data,包括 label 和 icon,但 responsible 已转换为 workerUserId
  2499. };
  2500. return nodeObj;
  2501. }),
  2502. edges: edges.map(e => {
  2503. // edges的source和target也需要改为对应的uuid(值相同)
  2504. return {
  2505. id: e.id,
  2506. source: nodeIdMap.get(e.source) || e.source, // 使用映射后的值
  2507. target: nodeIdMap.get(e.target) || e.target, // 使用映射后的值
  2508. sourceHandle: e.sourceHandle,
  2509. targetHandle: e.targetHandle,
  2510. type: e.type,
  2511. };
  2512. }),
  2513. };
  2514. setExportContent(JSON.stringify(exportData, null, 2));
  2515. setExportVisible(true);
  2516. }, [nodes, edges, loadNodeCache]);
  2517. // 复制JSON到剪贴板
  2518. const handleCopyJson = useCallback(async () => {
  2519. console.log('复制按钮被点击,exportContent长度:', exportContent?.length);
  2520. if (!exportContent || exportContent.trim() === '') {
  2521. console.warn('exportContent为空');
  2522. message.warning('没有可复制的内容');
  2523. return;
  2524. }
  2525. try {
  2526. // 优先使用现代 Clipboard API
  2527. if (navigator.clipboard && navigator.clipboard.writeText) {
  2528. console.log('使用 Clipboard API');
  2529. await navigator.clipboard.writeText(exportContent);
  2530. console.log('复制成功');
  2531. message.success('复制成功!JSON已复制到剪贴板');
  2532. return;
  2533. }
  2534. throw new Error('Clipboard API not available');
  2535. } catch (error) {
  2536. console.log('Clipboard API失败,使用降级方案:', error);
  2537. // 降级方案:使用传统方法
  2538. try {
  2539. const textArea = document.createElement('textarea');
  2540. textArea.value = exportContent;
  2541. textArea.style.position = 'fixed';
  2542. textArea.style.top = '0';
  2543. textArea.style.left = '0';
  2544. textArea.style.width = '2em';
  2545. textArea.style.height = '2em';
  2546. textArea.style.padding = '0';
  2547. textArea.style.border = 'none';
  2548. textArea.style.outline = 'none';
  2549. textArea.style.boxShadow = 'none';
  2550. textArea.style.background = 'transparent';
  2551. textArea.style.opacity = '0';
  2552. textArea.readOnly = true;
  2553. textArea.setAttribute('contenteditable', 'true');
  2554. document.body.appendChild(textArea);
  2555. textArea.focus();
  2556. textArea.select();
  2557. textArea.setSelectionRange(0, exportContent.length);
  2558. const successful = document.execCommand('copy');
  2559. document.body.removeChild(textArea);
  2560. if (successful) {
  2561. console.log('传统方法复制成功');
  2562. message.success('复制成功!JSON已复制到剪贴板');
  2563. } else {
  2564. console.warn('传统方法复制失败');
  2565. message.error('复制失败,请手动复制');
  2566. }
  2567. } catch (err) {
  2568. console.error('复制失败:', err);
  2569. message.error('复制失败,请手动复制');
  2570. }
  2571. }
  2572. }, [exportContent]);
  2573. // 导入流程 JSON
  2574. const handleImportJson = useCallback(() => {
  2575. try {
  2576. const data = JSON.parse(importJson);
  2577. if (!data.nodes || !Array.isArray(data.nodes)) {
  2578. toast.error('JSON 格式错误:缺少 nodes 数组');
  2579. return;
  2580. }
  2581. if (!data.edges || !Array.isArray(data.edges)) {
  2582. toast.error('JSON 格式错误:缺少 edges 数组');
  2583. return;
  2584. }
  2585. // 创建uuid到id的映射(导入时uuid改回id)
  2586. const uuidToIdMap = new Map<string, string>();
  2587. data.nodes.forEach((node: any) => {
  2588. // 兼容处理:支持uuid字段或id字段
  2589. const nodeId = node.uuid || node.id;
  2590. uuidToIdMap.set(nodeId, nodeId); // 值保持不变
  2591. });
  2592. // 还原节点,确保 data 包含所有配置字段
  2593. const importedNodes = data.nodes.map((node: any) => {
  2594. const nodeData = node.data || {};
  2595. // 兼容处理:支持uuid字段或id字段
  2596. const nodeId = node.uuid || node.id;
  2597. // 如果顶层有 nodeName 和 nodeIcon,将它们还原到 data 中
  2598. const topLevelLabel = node.nodeName || '';
  2599. const topLevelIcon = node.nodeIcon || '';
  2600. // 从顶层读取四个模板代码字段(与 uuid 同级)
  2601. const topLevelSmsTemplateCode = node.smsTemplateCode;
  2602. const topLevelMessageTemplateCode = node.messageTemplateCode;
  2603. const topLevelEmailTemplateCode = node.emailTemplateCode;
  2604. const topLevelAppTemplateCode = node.appTemplateCode;
  2605. // 兼容处理:优先使用 workerUserId,如果没有则使用 responsible(向后兼容),并确保值是字符串
  2606. const workerUserIdValue = nodeData.workerUserId !== undefined && nodeData.workerUserId !== null && nodeData.workerUserId !== ''
  2607. ? String(nodeData.workerUserId)
  2608. : (nodeData.responsible !== undefined && nodeData.responsible !== null && nodeData.responsible !== ''
  2609. ? String(nodeData.responsible)
  2610. : '');
  2611. // 确保所有字段都存在,使用默认值填充缺失的字段
  2612. const completeData = {
  2613. label: topLevelLabel || nodeData.label || '', // 优先使用顶层的 nodeName
  2614. type: nodeData.type || node.type,
  2615. nodeId: nodeData.nodeId || '',
  2616. icon: topLevelIcon || nodeData.icon || nodeData.type || node.type, // 优先使用顶层的 nodeIcon
  2617. responsible: workerUserIdValue, // 内部仍使用 responsible 字段名,但值来自 workerUserId 或 responsible
  2618. workerUserId: workerUserIdValue, // 同时保存 workerUserId 字段,确保数据一致性
  2619. remark: nodeData.remark || '',
  2620. formId: nodeData.formId || nodeData.submitForm ? String(nodeData.formId || nodeData.submitForm) : '', // 兼容旧的 submitForm 字段,确保是字符串类型
  2621. isolationType: nodeData.isolationType || '',
  2622. isolationPoints: Array.isArray(nodeData.isolationPoints) ? nodeData.isolationPoints : [],
  2623. isolationNode: Array.isArray(nodeData.isolationNode) ? nodeData.isolationNode : [],
  2624. isolationNodeUuid: nodeData.isolationNodeUuid || '',
  2625. lockPerson: nodeData.lockPerson || '',
  2626. coLockPersons: Array.isArray(nodeData.coLockPersons) ? nodeData.coLockPersons : [],
  2627. notificationMethods: nodeData.notificationMethods || {
  2628. sms: false,
  2629. message: false,
  2630. email: false,
  2631. app: false,
  2632. },
  2633. notificationPerson: nodeData.notificationPerson || '',
  2634. notificationTime: nodeData.notificationTime || '',
  2635. smsTemplateCode: topLevelSmsTemplateCode !== undefined ? topLevelSmsTemplateCode : (nodeData.smsTemplateCode || 'false'),
  2636. messageTemplateCode: topLevelMessageTemplateCode !== undefined ? topLevelMessageTemplateCode : (nodeData.messageTemplateCode || 'false'),
  2637. emailTemplateCode: topLevelEmailTemplateCode !== undefined ? topLevelEmailTemplateCode : (nodeData.emailTemplateCode || 'false'),
  2638. appTemplateCode: topLevelAppTemplateCode !== undefined ? topLevelAppTemplateCode : (nodeData.appTemplateCode || 'false'),
  2639. ...nodeData, // 保留其他可能的字段
  2640. };
  2641. return {
  2642. id: nodeId, // 使用uuid或id作为id
  2643. type: node.type,
  2644. position: node.position || { x: 0, y: 0 },
  2645. data: completeData,
  2646. };
  2647. });
  2648. // 还原连接线(source和target需要从uuid映射回id)
  2649. const importedEdges = data.edges.map((edge: any) => {
  2650. // 兼容处理:如果source/target是uuid,映射回id;如果是id,直接使用
  2651. const sourceId = uuidToIdMap.get(edge.source) || edge.source;
  2652. const targetId = uuidToIdMap.get(edge.target) || edge.target;
  2653. return {
  2654. id: edge.id,
  2655. source: sourceId,
  2656. target: targetId,
  2657. sourceHandle: edge.sourceHandle,
  2658. targetHandle: edge.targetHandle,
  2659. type: edge.type || 'straight',
  2660. animated: false,
  2661. style: { strokeWidth: 2, stroke: '#000000' },
  2662. markerStart: {
  2663. type: 'arrowclosed',
  2664. color: '#000000',
  2665. },
  2666. data: {
  2667. fixedLength: edge.data?.fixedLength || 150, // 保留导入的 fixedLength 或设置为默认值 150
  2668. },
  2669. };
  2670. });
  2671. // 更新节点和边
  2672. setNodes(importedNodes);
  2673. setEdges(importedEdges);
  2674. // 缓存每个节点的配置(确保缓存包含完整数据)
  2675. importedNodes.forEach((node: Node) => {
  2676. if (node.data) {
  2677. saveNodeCache(node.id, node.data);
  2678. }
  2679. });
  2680. // 如果当前有选中的节点,重新加载其配置
  2681. if (selectedNode) {
  2682. const updatedNode = importedNodes.find(n => n.id === selectedNode.id);
  2683. if (updatedNode) {
  2684. const cache = loadNodeCache(updatedNode.id);
  2685. const source = cache || updatedNode.data || {};
  2686. const config = nodeConfigs.find(c => c.type === source.type);
  2687. setNodeConfig({
  2688. nodeName: source.label || config?.label || '',
  2689. nodeIcon: source.icon || source.type || '',
  2690. responsible: source.responsible || '',
  2691. remark: source.remark || '',
  2692. formId: source.formId || source.submitForm ? String(source.formId || source.submitForm) : '', // 兼容旧的 submitForm 字段,确保是字符串类型
  2693. isolationType: source.isolationType || '',
  2694. isolationPoints: source.isolationPoints || [],
  2695. isolationNode: Array.isArray(source.isolationNode) ? source.isolationNode : (source.isolationNode ? [source.isolationNode] : []),
  2696. isolationNodeUuid: source.isolationNodeUuid || '',
  2697. lockPerson: source.lockPerson || '',
  2698. coLockPersons: source.coLockPersons || [],
  2699. notificationMethods: source.notificationMethods || {
  2700. sms: false,
  2701. message: false,
  2702. email: false,
  2703. app: false,
  2704. },
  2705. notificationPerson: source.notificationPerson || '',
  2706. notificationTime: source.notificationTime || '',
  2707. smsTemplateCode: source.smsTemplateCode || 'false',
  2708. messageTemplateCode: source.messageTemplateCode || 'false',
  2709. emailTemplateCode: source.emailTemplateCode || 'false',
  2710. appTemplateCode: source.appTemplateCode || 'false',
  2711. });
  2712. setSelectedNode(updatedNode);
  2713. } else {
  2714. // 如果选中的节点不在导入的数据中,清除选择
  2715. setSelectedNode(null);
  2716. }
  2717. }
  2718. // 保存历史
  2719. const newHistory = [{ nodes: importedNodes, edges: importedEdges }];
  2720. setHistory(newHistory);
  2721. setHistoryIndex(0);
  2722. // 更新初始状态,用于检测未保存的更改
  2723. initialNodesRef.current = JSON.parse(JSON.stringify(importedNodes));
  2724. initialEdgesRef.current = JSON.parse(JSON.stringify(importedEdges));
  2725. setHasUnsavedChanges(false);
  2726. toast.success('流程导入成功');
  2727. setImportVisible(false);
  2728. setImportJson('');
  2729. } catch (error: any) {
  2730. toast.error(`导入失败:${error.message || 'JSON 格式错误'}`);
  2731. }
  2732. }, [importJson, setNodes, setEdges, saveNodeCache, loadNodeCache, selectedNode, nodeConfigs, setNodeConfig, setSelectedNode, history, setHistory, setHistoryIndex]);
  2733. // 获取节点描述
  2734. const getNodeDescription = (type: string) => {
  2735. const descriptions: { [key: string]: string } = {
  2736. createJob: '该节点为作业创建人员创建作业录入信息开始节点。',
  2737. confirm: '该节点为作业确认,为"上一节点"操作分配确认模式及确认人员权限',
  2738. review: '该节点为作业审核,为"上一节点"操作分配审核模式及审核人员权限',
  2739. inputInfo: '该节点为作业录入提交,可提交信息或图片,主要为"信息确认"',
  2740. isolation: '该节点为作业隔离类型选择,主要包括盲板,上锁挂牌,拆除等。',
  2741. releaseIsolation: '该节点为作业隔离类型选择,主要包括盲板,上锁挂牌,拆除等。',
  2742. returnLock: '该节点为还锁操作,归还钥匙,确认隔离操作完成',
  2743. complete: '该节点为流程结束点',
  2744. };
  2745. return descriptions[type] || '该节点的功能描述。';
  2746. };
  2747. return (
  2748. <div className="h-screen w-screen flex flex-col bg-gray-50">
  2749. {/* 顶部工具栏 */}
  2750. <div className="h-14 bg-white border-b border-gray-200 flex items-center justify-between px-4 shadow-sm z-10">
  2751. <div className="flex items-center gap-2">
  2752. <span className="text-lg font-bold text-blue-600 mr-2">流程设计器</span>
  2753. <div className="h-5 w-px bg-gray-300 mx-1" />
  2754. {workflowDetail?.name && (
  2755. <>
  2756. <span className="text-gray-700 font-medium mr-4" style={{marginRight:'10px',marginLeft:'50px',fontSize:'17px',fontWeight:'bold'}}>{workflowDetail.name}</span>
  2757. <div className="h-5 w-px bg-gray-300 mx-2" />
  2758. </>
  2759. )}
  2760. <Button
  2761. type="primary"
  2762. className="flex items-center gap-1.5"
  2763. onClick={handleSave}
  2764. >
  2765. <SaveOutlined />
  2766. <span>保存</span>
  2767. </Button>
  2768. <div className="h-5 w-px bg-gray-300 mx-1" />
  2769. <Button
  2770. size="small"
  2771. className="flex items-center gap-1.5"
  2772. onClick={handleBack}
  2773. >
  2774. <ArrowLeftOutlined />
  2775. <span>返回</span>
  2776. </Button>
  2777. <div className="h-5 w-px bg-gray-300 mx-1" />
  2778. <Button
  2779. size="small"
  2780. className="flex items-center gap-1.5"
  2781. onClick={handleUndo}
  2782. disabled={historyIndex <= 0}
  2783. >
  2784. <UndoOutlined />
  2785. <span>撤销</span>
  2786. </Button>
  2787. <Button
  2788. size="small"
  2789. className="flex items-center gap-1.5"
  2790. onClick={handleRedo}
  2791. disabled={historyIndex >= history.length - 1}
  2792. >
  2793. <RedoOutlined />
  2794. <span>重做</span>
  2795. </Button>
  2796. <Button
  2797. size="small"
  2798. className="flex items-center gap-1.5"
  2799. onClick={handleExportJson}
  2800. >
  2801. <UploadOutlined />
  2802. <span>导出JSON</span>
  2803. </Button>
  2804. <Button
  2805. size="small"
  2806. className="flex items-center gap-1.5"
  2807. onClick={() => setImportVisible(true)}
  2808. >
  2809. <DownloadOutlined />
  2810. <span>导入JSON</span>
  2811. </Button>
  2812. <div className="h-5 w-px bg-gray-300 mx-1" />
  2813. <Button
  2814. size="small"
  2815. icon={<ZoomOutOutlined />}
  2816. onClick={handleZoomOut}
  2817. />
  2818. <span className="text-sm text-gray-600 px-2 min-w-[50px] text-center">
  2819. {Math.round(zoom * 100)}%
  2820. </span>
  2821. <Button
  2822. size="small"
  2823. icon={<ZoomInOutlined />}
  2824. onClick={handleZoomIn}
  2825. />
  2826. </div>
  2827. </div>
  2828. {/* 主内容区 */}
  2829. <div className="flex-1 flex overflow-hidden">
  2830. {/* 左侧节点面板 */}
  2831. <div className="w-56 bg-gray-50 border-r border-gray-200 overflow-y-auto">
  2832. <div className="p-2 space-y-3">
  2833. {nodeConfigs.filter(config => config.type !== 'returnLock').map((config) => {
  2834. const Icon = config.icon;
  2835. return (
  2836. <div
  2837. key={config.type}
  2838. draggable
  2839. onDragStart={(e) => onDragStart(e, config.type)}
  2840. className={`${config.bgColor} ${config.borderColor} border p-3 rounded-2xl shadow-md flex flex-col items-center justify-center gap-2 cursor-move transition-all hover:shadow-lg`}
  2841. style={{
  2842. borderRadius: '16px',
  2843. minWidth: '80px',
  2844. minHeight: '80px',
  2845. backgroundColor: config.bgColorCustom || undefined
  2846. }}
  2847. >
  2848. <Icon
  2849. className={`${config.iconColor} text-2xl`}
  2850. style={{ color: config.iconColorCustom || undefined }}
  2851. />
  2852. <span className="text-xs font-medium text-center leading-tight" style={{ color: '#6b7280' }}>
  2853. {config.label}
  2854. </span>
  2855. </div>
  2856. );
  2857. })}
  2858. </div>
  2859. </div>
  2860. {/* 中间画布 */}
  2861. <div className="flex-1 relative bg-white" ref={reactFlowWrapper}>
  2862. <ReactFlow
  2863. nodes={nodes}
  2864. edges={edges}
  2865. onNodesChange={onNodesChange}
  2866. onEdgesChange={onEdgesChangeWithDirty}
  2867. onConnect={onConnect}
  2868. onEdgeUpdate={onEdgeUpdate}
  2869. isValidConnection={isValidConnection}
  2870. onNodeClick={onNodeClick}
  2871. onNodeContextMenu={onNodeContextMenu}
  2872. onEdgeClick={onEdgeClick}
  2873. onPaneClick={onPaneClick}
  2874. onDrop={onDrop}
  2875. onDragOver={onDragOver}
  2876. deleteKeyCode={['Delete', 'Backspace']}
  2877. nodeTypes={nodeTypes as NodeTypes}
  2878. edgeTypes={edgeTypes}
  2879. fitView
  2880. onInit={setReactFlowInstance}
  2881. onMove={(_, viewport) => setZoom(viewport.zoom)}
  2882. connectionMode={ConnectionMode.Loose}
  2883. connectionRadius={40}
  2884. snapToGrid={true}
  2885. snapGrid={[16, 16]}
  2886. defaultEdgeOptions={{
  2887. style: { strokeWidth: 2, stroke: '#000000' },
  2888. markerStart: {
  2889. type: 'arrowclosed',
  2890. color: '#000000',
  2891. },
  2892. // 不设置默认type,让onConnect中的逻辑决定
  2893. }}
  2894. edgesUpdatable={true}
  2895. edgesFocusable={true}
  2896. edgeUpdaterRadius={10}
  2897. >
  2898. <Controls
  2899. className="!bg-white !border !border-gray-200 !rounded-lg !shadow-md"
  2900. style={{ position: 'absolute', top: '10px', right: '10px', bottom: 'auto', left: 'auto' }}
  2901. />
  2902. <Background variant={BackgroundVariant.Lines} gap={16} size={1} color="#e5e7eb" />
  2903. <MiniMap
  2904. nodeColor={(node) => {
  2905. const config = nodeConfigs.find(c => c.type === node.type);
  2906. if (config?.color === 'bg-blue-500') return '#3b82f6';
  2907. if (config?.color === 'bg-green-500') return '#10b981';
  2908. if (config?.color === 'bg-orange-500') return '#f97316';
  2909. if (config?.color === 'bg-red-500') return '#ef4444';
  2910. if (config?.color === 'bg-yellow-500') return '#eab308';
  2911. if (config?.color === 'bg-indigo-500') return '#6366f1';
  2912. if (config?.color === 'bg-purple-500') return '#a855f7';
  2913. return '#6b7280';
  2914. }}
  2915. maskColor="rgba(0, 0, 0, 0.05)"
  2916. className="!bg-white !border !border-gray-200 !rounded-lg"
  2917. />
  2918. </ReactFlow>
  2919. </div>
  2920. {/* 右侧属性面板 */}
  2921. {!rightPanelCollapsed && (
  2922. <div className="w-80 bg-gray-50 border-l border-gray-200 overflow-y-auto">
  2923. {selectedNode ? (
  2924. <div className="h-full flex flex-col">
  2925. {/* 头部 */}
  2926. <div className="p-4 bg-white border-b border-gray-200 flex items-center justify-between sticky top-0 z-10 shadow-sm">
  2927. <h3 className="text-base font-semibold text-gray-900">
  2928. {nodeConfig.nodeName || selectedNode.data?.label || '节点'} 设置
  2929. </h3>
  2930. <div className="flex items-center gap-2">
  2931. <Tooltip title="删除当前节点">
  2932. <Button
  2933. type="text"
  2934. danger
  2935. size="small"
  2936. onClick={() => handleDeleteNode()}
  2937. className="text-red-500 hover:text-red-600"
  2938. icon={<DeleteOutlined />}
  2939. />
  2940. </Tooltip>
  2941. <Tooltip title="收缩面板">
  2942. <button
  2943. onClick={() => setRightPanelCollapsed(!rightPanelCollapsed)}
  2944. className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
  2945. >
  2946. <MenuFoldOutlined className="text-gray-500" />
  2947. </button>
  2948. </Tooltip>
  2949. </div>
  2950. </div>
  2951. {/* 内容 */}
  2952. <div className="flex-1 p-4 overflow-y-auto">
  2953. <Tabs
  2954. activeKey={activeTabKey}
  2955. onChange={(key) => {
  2956. // 如果是创建作业节点,不允许切换到"提交表单"tab
  2957. if (key === 'form' && selectedNode.data?.type === 'createJob') {
  2958. setActiveTabKey('info');
  2959. } else {
  2960. setActiveTabKey(key);
  2961. }
  2962. }}
  2963. className="[&_.ant-tabs-tab]:!px-4 [&_.ant-tabs-tab]:!py-2.5 [&_.ant-tabs-tab-active]:!text-blue-600 [&_.ant-tabs-tab-active]:!font-semibold [&_.ant-tabs-ink-bar]:!bg-blue-600 [&_.ant-tabs-tab]:!text-gray-600 [&_.ant-tabs-tab]:!border-b-2 [&_.ant-tabs-tab]:!border-transparent [&_.ant-tabs-tab:hover]:!text-blue-500"
  2964. items={[
  2965. {
  2966. key: 'info',
  2967. label: '节点信息',
  2968. children: (
  2969. <div className="space-y-5">
  2970. {/* 节点名称 */}
  2971. <div>
  2972. {/* 描述 - 创建作业、确认、审核、录入信息、隔离方案和解除隔离节点显示在节点名称顶部 */}
  2973. {(selectedNode.data?.type === 'createJob' || selectedNode.data?.type === 'confirm' || selectedNode.data?.type === 'review' || selectedNode.data?.type === 'inputInfo' || selectedNode.data?.type === 'isolation' || selectedNode.data?.type === 'releaseIsolation' || selectedNode.data?.type === 'returnLock' || selectedNode.data?.type === 'complete') && (
  2974. <div className="text-xs text-gray-500 leading-relaxed mb-2">
  2975. {getNodeDescription(selectedNode.data?.type || '')}
  2976. </div>
  2977. )}
  2978. <label className="block text-sm font-medium text-gray-700 mb-2">
  2979. 节点名称 <span className="text-red-500" style={{ color: '#ef4444' }}>*</span>
  2980. {selectedNode.data?.type && (
  2981. <span className="text-gray-500 font-normal ml-2">({selectedNode.data.type})</span>
  2982. )}
  2983. </label>
  2984. {selectedNode.data?.type !== 'createJob' && selectedNode.data?.type !== 'confirm' && (
  2985. <p className="text-xs text-gray-500 mb-2.5 leading-relaxed">
  2986. (默认名称: {nodeConfigs.find(c => c.type === selectedNode.data?.type)?.label || '节点名称'},可根据需求调整)
  2987. </p>
  2988. )}
  2989. {selectedNode.data?.type === 'createJob' && (
  2990. <p className="text-xs text-gray-500 mb-2.5 leading-relaxed">
  2991. (默认名称: {nodeConfigs.find(c => c.type === selectedNode.data?.type)?.label || '节点名称'},可根据需求调整)
  2992. </p>
  2993. )}
  2994. <Input
  2995. value={nodeConfig.nodeName || ''}
  2996. onChange={(e) =>
  2997. setNodeConfig({ ...nodeConfig, nodeName: e.target.value })
  2998. }
  2999. placeholder="请输入节点名称"
  3000. className="rounded-lg border-gray-200 h-10"
  3001. />
  3002. </div>
  3003. {/* 底部描述已移至顶部,不再在此显示 */}
  3004. {/* 显示图标 */}
  3005. <div>
  3006. <label className="block text-sm font-medium text-gray-700 mb-2">
  3007. 显示图标
  3008. </label>
  3009. <Popover
  3010. content={
  3011. <div style={{ width: '400px', maxHeight: '600px', overflowY: 'auto', padding: '12px' }}>
  3012. {['开始', '确认', '审核', '录入', '能量隔离', '解除隔离', '结束'].map((category) => {
  3013. const iconPaths = generateIconPaths(category);
  3014. return (
  3015. <div key={category} className="mb-6">
  3016. {/* 分类标题带蓝色装饰条 */}
  3017. <div className="flex items-center mb-3">
  3018. <div className="w-1 h-5 bg-blue-500 rounded-full" style={{ minWidth: '4px', marginRight: '8px' }}></div>
  3019. <span className="text-sm font-bold text-gray-800">{category}</span>
  3020. </div>
  3021. {/* 图标网格 */}
  3022. {iconPaths.length > 0 ? (
  3023. <div className="grid grid-cols-5 gap-3" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
  3024. {iconPaths.map((iconItem) => {
  3025. const isSelected = nodeConfig.nodeIcon === iconItem.fileName;
  3026. return (
  3027. <div
  3028. key={iconItem.id}
  3029. onClick={() => {
  3030. // 保存文件名而不是路径
  3031. setNodeConfig({ ...nodeConfig, nodeIcon: iconItem.fileName });
  3032. setIconPickerOpen(false);
  3033. // 立即更新节点显示
  3034. setNodes((nds) => {
  3035. return nds.map((node) => {
  3036. if (node.id === selectedNode.id) {
  3037. return {
  3038. ...node,
  3039. data: {
  3040. ...node.data,
  3041. icon: iconItem.fileName, // 保存文件名
  3042. },
  3043. };
  3044. }
  3045. return node;
  3046. });
  3047. });
  3048. }}
  3049. className={`
  3050. rounded-lg border-2 cursor-pointer transition-all
  3051. flex items-center justify-center overflow-hidden
  3052. hover:border-blue-400 hover:bg-blue-50
  3053. ${isSelected ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'}
  3054. `}
  3055. style={{ width: '25px', height: '25px' }}
  3056. title={category}
  3057. >
  3058. <img
  3059. src={iconItem.path}
  3060. alt={category}
  3061. className="w-6 h-6 object-contain"
  3062. onError={(e) => {
  3063. // 如果图片加载失败,显示占位符
  3064. console.error('ProcessDesigner: 图标加载失败', {
  3065. path: iconItem.path,
  3066. id: iconItem.id,
  3067. category
  3068. });
  3069. const target = e.target as HTMLImageElement;
  3070. target.style.display = 'none';
  3071. // 显示错误占位符
  3072. const parent = target.parentElement;
  3073. if (parent && !parent.querySelector('.error-placeholder')) {
  3074. const placeholder = document.createElement('div');
  3075. placeholder.className = 'error-placeholder text-xs text-gray-400';
  3076. placeholder.textContent = '?';
  3077. parent.appendChild(placeholder);
  3078. }
  3079. }}
  3080. onLoad={() => {
  3081. // console.log('ProcessDesigner: 图标加载成功', iconItem.path);
  3082. }}
  3083. />
  3084. </div>
  3085. );
  3086. })}
  3087. </div>
  3088. ) : (
  3089. <div className="text-sm text-gray-500 py-4 text-center">
  3090. 该分类暂无图标(请检查控制台日志)
  3091. </div>
  3092. )}
  3093. </div>
  3094. );
  3095. })}
  3096. </div>
  3097. }
  3098. trigger="click"
  3099. open={iconPickerOpen}
  3100. onOpenChange={setIconPickerOpen}
  3101. placement="right"
  3102. >
  3103. <div className="border border-gray-200 rounded-lg p-3 bg-white flex items-center gap-3 cursor-pointer hover:border-blue-400 transition-colors shadow-sm">
  3104. {(() => {
  3105. // 如果设置了自定义图标(图片文件名),显示图片
  3106. if (nodeConfig.nodeIcon && /^\d+\.png$/.test(nodeConfig.nodeIcon)) {
  3107. const iconPath = getIconPathByFileName(nodeConfig.nodeIcon);
  3108. if (iconPath) {
  3109. return (
  3110. <>
  3111. <div className="w-12 h-12 rounded-lg border border-gray-200 bg-white flex items-center justify-center overflow-hidden" style={{ borderRadius: '12px' }}>
  3112. <img
  3113. src={iconPath}
  3114. alt="节点图标"
  3115. className="w-full h-full object-contain"
  3116. onError={(e) => {
  3117. // 如果图片加载失败,显示默认图标
  3118. console.error('图标加载失败:', nodeConfig.nodeIcon);
  3119. }}
  3120. />
  3121. </div>
  3122. <span className="text-sm text-gray-600">选择图标</span>
  3123. </>
  3124. );
  3125. }
  3126. }
  3127. // 否则使用默认图标
  3128. let Icon = FileTextOutlined;
  3129. let config = null;
  3130. const iconType = selectedNode.data?.type;
  3131. config = nodeConfigs.find(c => c.type === iconType);
  3132. Icon = config?.icon || FileTextOutlined;
  3133. return (
  3134. <>
  3135. <div className={`${config?.bgColor || 'bg-gray-50'} ${config?.borderColor || 'border-gray-100'} border p-3 rounded-xl shadow-sm flex items-center justify-center`} style={{ borderRadius: '12px' }}>
  3136. <Icon
  3137. className={`${config?.iconColor || 'text-gray-600'} text-lg`}
  3138. style={{ color: config?.iconColorCustom || undefined }}
  3139. />
  3140. </div>
  3141. <span className="text-sm text-gray-600">选择图标</span>
  3142. </>
  3143. );
  3144. })()}
  3145. </div>
  3146. </Popover>
  3147. </div>
  3148. {/* 负责人 - 确认节点显示在图标下方 */}
  3149. {selectedNode.data?.type === 'confirm' && (
  3150. <div>
  3151. <label className="block text-sm font-medium text-gray-700 mb-2">
  3152. 负责人
  3153. </label>
  3154. <Select
  3155. value={nodeConfig.responsible || undefined}
  3156. onChange={(value) =>
  3157. setNodeConfig({ ...nodeConfig, responsible: value || '' })
  3158. }
  3159. placeholder="请选择负责人"
  3160. className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!h-10"
  3161. allowClear
  3162. >
  3163. {drawerUsers.map(user => (
  3164. <Select.Option key={user.id} value={String(user.id)}>{user.nickname || user.username}</Select.Option>
  3165. ))}
  3166. </Select>
  3167. <p className="text-xs text-gray-500 mt-1.5 leading-relaxed">
  3168. 对该任务或步骤节点进行处理的人员,若不选则需要在创建作业时进行选择。
  3169. </p>
  3170. </div>
  3171. )}
  3172. {/* 负责人 - 审核节点显示在图标下方 */}
  3173. {selectedNode.data?.type === 'review' && (
  3174. <div>
  3175. <label className="block text-sm font-medium text-gray-700 mb-2">
  3176. 负责人
  3177. </label>
  3178. <Select
  3179. value={nodeConfig.responsible || undefined}
  3180. onChange={(value) =>
  3181. setNodeConfig({ ...nodeConfig, responsible: value || '' })
  3182. }
  3183. placeholder="请选择负责人"
  3184. className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!h-10"
  3185. allowClear
  3186. >
  3187. {drawerUsers.map(user => (
  3188. <Select.Option key={user.id} value={String(user.id)}>{user.nickname || user.username}</Select.Option>
  3189. ))}
  3190. </Select>
  3191. <p className="text-xs text-gray-500 mt-1.5 leading-relaxed">
  3192. 对该任务或步骤节点进行处理的人员,若不选则需要在创建作业时进行选择。
  3193. </p>
  3194. </div>
  3195. )}
  3196. {/* 负责人 - 创建作业、隔离方案和解除隔离节点不显示 */}
  3197. {selectedNode.data?.type !== 'createJob' && selectedNode.data?.type !== 'confirm' && selectedNode.data?.type !== 'review' && selectedNode.data?.type !== 'isolation' && selectedNode.data?.type !== 'releaseIsolation' && (
  3198. <div>
  3199. <label className="block text-sm font-medium text-gray-700 mb-2">
  3200. 负责人
  3201. </label>
  3202. <Select
  3203. value={nodeConfig.responsible || undefined}
  3204. onChange={(value) =>
  3205. setNodeConfig({ ...nodeConfig, responsible: value || '' })
  3206. }
  3207. placeholder="请选择负责人"
  3208. className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!h-10"
  3209. allowClear
  3210. >
  3211. {drawerUsers.map(user => (
  3212. <Select.Option key={user.id} value={String(user.id)}>{user.nickname || user.username}</Select.Option>
  3213. ))}
  3214. </Select>
  3215. <p className="text-xs text-gray-500 mt-1.5 leading-relaxed">
  3216. 对该任务或步骤节点进行处理的人员,若不选则需要在创建作业时进行选择
  3217. </p>
  3218. </div>
  3219. )}
  3220. {/* 备注 - 创建作业、确认、审核、录入信息、隔离方案、解除隔离、还锁和完成节点不显示 */}
  3221. {selectedNode.data?.type !== 'createJob' && selectedNode.data?.type !== 'confirm' && selectedNode.data?.type !== 'review' && selectedNode.data?.type !== 'inputInfo' && selectedNode.data?.type !== 'isolation' && selectedNode.data?.type !== 'releaseIsolation' && selectedNode.data?.type !== 'returnLock' && selectedNode.data?.type !== 'complete' && (
  3222. <div>
  3223. <label className="block text-sm font-medium text-gray-700 mb-2">
  3224. 备注
  3225. </label>
  3226. <Input.TextArea
  3227. value={nodeConfig.remark || ''}
  3228. onChange={(e) =>
  3229. setNodeConfig({ ...nodeConfig, remark: e.target.value })
  3230. }
  3231. placeholder="请输入备注"
  3232. rows={3}
  3233. className="rounded-lg border-gray-200"
  3234. />
  3235. </div>
  3236. )}
  3237. <div className="flex gap-2">
  3238. <Button
  3239. type="primary"
  3240. onClick={handleSaveWithoutNavigate}
  3241. className="flex-1 rounded-lg"
  3242. size="large"
  3243. >
  3244. <SaveOutlined />
  3245. <span className="ml-1">保存</span>
  3246. </Button>
  3247. </div>
  3248. </div>
  3249. ),
  3250. },
  3251. {
  3252. key: 'form',
  3253. label: '提交表单',
  3254. children: (
  3255. <div className="space-y-5">
  3256. <div>
  3257. <label className="block text-sm font-medium text-gray-700 mb-2">
  3258. 提交表单 <span className="text-red-500" style={{ color: '#ef4444' }}>*</span>
  3259. </label>
  3260. <div className="flex gap-2">
  3261. <Select
  3262. value={nodeConfig.formId || undefined}
  3263. onChange={(value) =>
  3264. setNodeConfig({ ...nodeConfig, formId: value ? String(value) : '' })
  3265. }
  3266. placeholder="请选择提交表单"
  3267. className="flex-1 [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!h-10"
  3268. allowClear
  3269. >
  3270. {formList.map(form => (
  3271. <Select.Option key={form.id} value={String(form.id)}>{form.name}</Select.Option>
  3272. ))}
  3273. </Select>
  3274. <Button
  3275. icon={<EyeOutlined />}
  3276. onClick={async () => {
  3277. if (!nodeConfig.formId) {
  3278. message.warning('请先选择表单');
  3279. return;
  3280. }
  3281. setFormPreviewLoading(true);
  3282. try {
  3283. const formData = await getForm(Number(nodeConfig.formId));
  3284. setFormPreviewData(formData);
  3285. // 解析表单配置和字段
  3286. setConfAndFields2(setFormPreviewDetailData, formData.conf, formData.fields);
  3287. setFormPreviewVisible(true);
  3288. } catch (error: any) {
  3289. message.error(error?.message || '获取表单详情失败');
  3290. } finally {
  3291. setFormPreviewLoading(false);
  3292. }
  3293. }}
  3294. disabled={!nodeConfig.formId}
  3295. loading={formPreviewLoading}
  3296. className="flex-shrink-0"
  3297. >
  3298. 预览
  3299. </Button>
  3300. </div>
  3301. </div>
  3302. {/* 隔离/方案 和 解除隔离 节点特有的字段 */}
  3303. {(selectedNode.data?.type === 'isolation' || selectedNode.data?.type === 'releaseIsolation') && (
  3304. <>
  3305. {/* 解除隔离节点:选择隔离节点 */}
  3306. {selectedNode.data?.type === 'releaseIsolation' && (
  3307. <div>
  3308. <label className="block text-sm font-medium text-gray-700 mb-2">
  3309. 选择隔离节点
  3310. </label>
  3311. <Select
  3312. value={nodeConfig.isolationNodeUuid || undefined}
  3313. onChange={(value) => {
  3314. setNodeConfig((prev) => {
  3315. const target = nodes.find(n => n.id === value && n.data?.type === 'isolation');
  3316. if (target) {
  3317. // 优先从缓存中读取,缓存中没有则从 node.data 读取
  3318. const cache = loadNodeCache(target.id);
  3319. const source = cache || target.data || {};
  3320. return {
  3321. ...prev,
  3322. isolationNodeUuid: value || '',
  3323. isolationType: source.isolationType || '',
  3324. isolationPoints: source.isolationPoints || [],
  3325. isolationNode: Array.isArray(source.isolationNode)
  3326. ? source.isolationNode
  3327. : (source.isolationNode ? [source.isolationNode] : []),
  3328. responsible: source.responsible || '',
  3329. lockPerson: source.lockPerson || '',
  3330. coLockPersons: source.coLockPersons || [],
  3331. };
  3332. }
  3333. return { ...prev, isolationNodeUuid: value || '' };
  3334. });
  3335. }}
  3336. placeholder="请选择隔离节点"
  3337. className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!h-10"
  3338. allowClear
  3339. >
  3340. {(() => {
  3341. // 找到所有与当前"解除隔离"节点连线的"隔离/方案"节点
  3342. // 连线方向:隔离节点(source) -> 解除隔离节点(target)
  3343. const currentNodeId = selectedNode?.id;
  3344. // 找到所有以当前节点为 target 的 edge(隔离节点指向解除隔离节点)
  3345. const incomingEdges = edges.filter(edge =>
  3346. String(edge.target) === String(currentNodeId)
  3347. );
  3348. // 也检查反向连线(解除隔离节点指向隔离节点,虽然不常见但兼容)
  3349. const outgoingEdges = edges.filter(edge =>
  3350. String(edge.source) === String(currentNodeId)
  3351. );
  3352. // 收集所有已连线的隔离节点ID
  3353. const connectedIsolationNodeIds = new Set<string>();
  3354. incomingEdges.forEach(edge => {
  3355. connectedIsolationNodeIds.add(String(edge.source));
  3356. });
  3357. outgoingEdges.forEach(edge => {
  3358. connectedIsolationNodeIds.add(String(edge.target));
  3359. });
  3360. // 获取所有隔离节点
  3361. const isolationNodes = nodes.filter(n => n.data?.type === 'isolation');
  3362. // 如果有连线,只显示已连线的隔离节点;否则显示所有隔离节点(兼容旧数据)
  3363. const nodesToShow = connectedIsolationNodeIds.size > 0
  3364. ? isolationNodes.filter(n => connectedIsolationNodeIds.has(String(n.id)))
  3365. : isolationNodes;
  3366. return nodesToShow.map(node => (
  3367. <Select.Option key={node.id} value={node.id}>
  3368. {node.data?.label || nodeConfigs.find(c => c.type === 'isolation')?.label || '隔离/方案'}
  3369. </Select.Option>
  3370. ));
  3371. })()}
  3372. </Select>
  3373. </div>
  3374. )}
  3375. {/* 隔离方式 - 隔离方案节点可编辑,解除隔离节点只读(根据选择的隔离节点自动填充) */}
  3376. <div>
  3377. <label className="block text-sm font-medium text-gray-700 mb-2">
  3378. 隔离方式
  3379. </label>
  3380. <Select
  3381. value={nodeConfig.isolationType || undefined}
  3382. onChange={(value) =>
  3383. setNodeConfig({ ...nodeConfig, isolationType: value || '' })
  3384. }
  3385. placeholder="请选择隔离方式"
  3386. className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!h-10"
  3387. allowClear
  3388. disabled={selectedNode.data?.type === 'releaseIsolation'}
  3389. >
  3390. {isolationTypeDictList.map((item) => (
  3391. <Select.Option key={item.id} value={item.value}>
  3392. {item.label}
  3393. </Select.Option>
  3394. ))}
  3395. </Select>
  3396. </div>
  3397. {/* 隔离点选择(可多选)- 隔离方案节点显示,解除隔离只读展示 */}
  3398. {(selectedNode.data?.type === 'isolation' || selectedNode.data?.type === 'releaseIsolation') && (
  3399. <div>
  3400. <label className="block text-sm font-medium text-gray-700 mb-2">
  3401. 隔离点选择(可多选)
  3402. </label>
  3403. <Select
  3404. mode="multiple"
  3405. value={nodeConfig.isolationPoints}
  3406. onChange={(value) =>
  3407. setNodeConfig({ ...nodeConfig, isolationPoints: value })
  3408. }
  3409. placeholder="请选择隔离点"
  3410. className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!min-h-10"
  3411. allowClear
  3412. disabled={selectedNode.data?.type === 'releaseIsolation'}
  3413. >
  3414. {isolationPoints.map((point: any, index) => (
  3415. <Select.Option key={point.pointId || point.id || `point-${index}`} value={point.pointId || point.id}>{point.pointName}</Select.Option>
  3416. ))}
  3417. </Select>
  3418. </div>
  3419. )}
  3420. {/* 盲板和拆除:显示负责人 */}
  3421. {/* 字典值:0=盲板,1=上锁挂牌,2=拆除 */}
  3422. {(nodeConfig.isolationType === '0' || nodeConfig.isolationType === '2') && (
  3423. <div>
  3424. <label className="block text-sm font-medium text-gray-700 mb-2">
  3425. 负责人
  3426. </label>
  3427. <Select
  3428. value={nodeConfig.responsible || undefined}
  3429. onChange={(value) =>
  3430. setNodeConfig({ ...nodeConfig, responsible: value || '' })
  3431. }
  3432. placeholder="请选择负责人"
  3433. className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!h-10"
  3434. allowClear
  3435. disabled={selectedNode.data?.type === 'releaseIsolation'}
  3436. >
  3437. {drawerUsers.map(user => (
  3438. <Select.Option key={user.id} value={user.id}>{user.nickname || user.username}</Select.Option>
  3439. ))}
  3440. </Select>
  3441. <p className="text-xs text-gray-500 mt-1.5 leading-relaxed">
  3442. 对该任务或步骤节点进行处理的人员,若不选则需要在创建作业时进行选择。
  3443. </p>
  3444. </div>
  3445. )}
  3446. {/* 上锁挂牌:显示上锁人和共锁人 */}
  3447. {nodeConfig.isolationType === '1' && (
  3448. <>
  3449. <div>
  3450. <label className="block text-sm font-medium text-gray-700 mb-2">
  3451. 上锁人
  3452. </label>
  3453. <Select
  3454. value={nodeConfig.lockPerson || undefined}
  3455. onChange={(value) =>
  3456. setNodeConfig({ ...nodeConfig, lockPerson: value || '' })
  3457. }
  3458. placeholder="请选择上锁人"
  3459. className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!h-10"
  3460. allowClear
  3461. disabled={selectedNode.data?.type === 'releaseIsolation'}
  3462. >
  3463. {lockerUsers.map(user => (
  3464. <Select.Option key={user.id} value={user.id}>{user.nickname || user.username}</Select.Option>
  3465. ))}
  3466. </Select>
  3467. </div>
  3468. <div>
  3469. <label className="block text-sm font-medium text-gray-700 mb-2">
  3470. 共锁人(可多选)
  3471. </label>
  3472. <Select
  3473. mode="multiple"
  3474. value={nodeConfig.coLockPersons}
  3475. onChange={(value) =>
  3476. setNodeConfig({ ...nodeConfig, coLockPersons: value })
  3477. }
  3478. placeholder="请选择共锁人"
  3479. className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!min-h-10"
  3480. allowClear
  3481. disabled={selectedNode.data?.type === 'releaseIsolation'}
  3482. >
  3483. {colockerUsers.map(user => (
  3484. <Select.Option key={user.id} value={user.id}>{user.nickname || user.username}</Select.Option>
  3485. ))}
  3486. </Select>
  3487. </div>
  3488. </>
  3489. )}
  3490. </>
  3491. )}
  3492. <div className="flex gap-2">
  3493. <Button
  3494. type="primary"
  3495. onClick={handleSaveWithoutNavigate}
  3496. className="flex-1 rounded-lg"
  3497. size="large"
  3498. >
  3499. <SaveOutlined />
  3500. <span className="ml-1">保存</span>
  3501. </Button>
  3502. </div>
  3503. </div>
  3504. ),
  3505. },
  3506. {
  3507. key: 'notification',
  3508. label: '通知消息',
  3509. children: (
  3510. <div className="space-y-5">
  3511. <div>
  3512. <label className="block text-sm font-medium text-gray-700 mb-3">
  3513. 通知方式
  3514. </label>
  3515. <div className="bg-gray-50 p-3 rounded-lg">
  3516. <div className="mb-3">
  3517. <Checkbox
  3518. checked={nodeConfig.notificationMethods.sms}
  3519. onChange={(e) =>
  3520. setNodeConfig({
  3521. ...nodeConfig,
  3522. notificationMethods: {
  3523. ...nodeConfig.notificationMethods,
  3524. sms: e.target.checked,
  3525. },
  3526. smsTemplateCode: e.target.checked ? 'true' : 'false',
  3527. })
  3528. }
  3529. >
  3530. 短信
  3531. </Checkbox>
  3532. </div>
  3533. <div className="mb-3">
  3534. <Checkbox
  3535. checked={nodeConfig.notificationMethods.message}
  3536. onChange={(e) =>
  3537. setNodeConfig({
  3538. ...nodeConfig,
  3539. notificationMethods: {
  3540. ...nodeConfig.notificationMethods,
  3541. message: e.target.checked,
  3542. },
  3543. messageTemplateCode: e.target.checked ? 'true' : 'false',
  3544. })
  3545. }
  3546. >
  3547. 站内信
  3548. </Checkbox>
  3549. </div>
  3550. <div className="mb-3">
  3551. <Checkbox
  3552. checked={nodeConfig.notificationMethods.email}
  3553. onChange={(e) =>
  3554. setNodeConfig({
  3555. ...nodeConfig,
  3556. notificationMethods: {
  3557. ...nodeConfig.notificationMethods,
  3558. email: e.target.checked,
  3559. },
  3560. emailTemplateCode: e.target.checked ? 'true' : 'false',
  3561. })
  3562. }
  3563. >
  3564. 邮件
  3565. </Checkbox>
  3566. </div>
  3567. <div>
  3568. <Checkbox
  3569. checked={nodeConfig.notificationMethods.app}
  3570. onChange={(e) =>
  3571. setNodeConfig({
  3572. ...nodeConfig,
  3573. notificationMethods: {
  3574. ...nodeConfig.notificationMethods,
  3575. app: e.target.checked,
  3576. },
  3577. appTemplateCode: e.target.checked ? 'true' : 'false',
  3578. })
  3579. }
  3580. >
  3581. APP通知
  3582. </Checkbox>
  3583. </div>
  3584. </div>
  3585. </div>
  3586. {/* <div>
  3587. <label className="block text-sm font-medium text-gray-700 mb-2">
  3588. 通知人
  3589. </label>
  3590. <Select
  3591. value={nodeConfig.notificationPerson || undefined}
  3592. onChange={(value) =>
  3593. setNodeConfig({ ...nodeConfig, notificationPerson: value || '' })
  3594. }
  3595. placeholder="请选择通知人"
  3596. className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!h-10"
  3597. allowClear
  3598. >
  3599. <Select.Option value="taskResponsible">任务负责人</Select.Option>
  3600. <Select.Option value="taskParticipant">任务参与人</Select.Option>
  3601. <Select.Option value="responsibleAndParticipant">负责人和参与人</Select.Option>
  3602. <Select.Option value="specifiedPerson">指定人</Select.Option>
  3603. </Select>
  3604. </div> */}
  3605. {/* <div>
  3606. <label className="block text-sm font-medium text-gray-700 mb-2">
  3607. 通知时间
  3608. </label>
  3609. <Select
  3610. value={nodeConfig.notificationTime || undefined}
  3611. onChange={(value) =>
  3612. setNodeConfig({ ...nodeConfig, notificationTime: value || '' })
  3613. }
  3614. placeholder="请选择通知时间"
  3615. className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!h-10"
  3616. allowClear
  3617. >
  3618. <Select.Option value="before">执行前(上一个节点结束后)</Select.Option>
  3619. <Select.Option value="after">执行后(该节点结束后)</Select.Option>
  3620. <Select.Option value="time">选择时间</Select.Option>
  3621. <Select.Option value="30min">任务开始前30分钟</Select.Option>
  3622. <Select.Option value="1h">任务开始前1小时</Select.Option>
  3623. <Select.Option value="2h">任务开始前2小时</Select.Option>
  3624. <Select.Option value="4h">任务开始前4小时</Select.Option>
  3625. <Select.Option value="5h">任务开始前5小时</Select.Option>
  3626. <Select.Option value="8h">任务开始前8小时</Select.Option>
  3627. <Select.Option value="12h">任务开始前12小时</Select.Option>
  3628. <Select.Option value="24h">任务开始前24小时</Select.Option>
  3629. <Select.Option value="48h">任务开始前48小时</Select.Option>
  3630. </Select>
  3631. </div> */}
  3632. <div className="flex gap-2">
  3633. <Button
  3634. type="primary"
  3635. onClick={handleSaveWithoutNavigate}
  3636. className="flex-1 rounded-lg"
  3637. size="large"
  3638. >
  3639. <SaveOutlined />
  3640. <span className="ml-1">保存</span>
  3641. </Button>
  3642. </div>
  3643. </div>
  3644. ),
  3645. },
  3646. ].filter(item => {
  3647. // 如果是创建作业节点,过滤掉提交表单tab
  3648. if (item.key === 'form' && selectedNode.data?.type === 'createJob') {
  3649. return false;
  3650. }
  3651. return true;
  3652. })}
  3653. />
  3654. </div>
  3655. </div>
  3656. ) : (
  3657. <div className="h-full flex flex-col">
  3658. {/* 头部 */}
  3659. <div className="p-4 bg-white border-b border-gray-200 flex items-center justify-end sticky top-0 z-10 shadow-sm">
  3660. <Tooltip title="收缩面板">
  3661. <button
  3662. onClick={() => setRightPanelCollapsed(!rightPanelCollapsed)}
  3663. className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
  3664. >
  3665. <MenuFoldOutlined className="text-gray-500" />
  3666. </button>
  3667. </Tooltip>
  3668. </div>
  3669. {/* 内容 */}
  3670. <div className="p-4 text-center text-gray-500 mt-20">
  3671. <p className="text-sm">请点击画布中的节点查看和编辑属性</p>
  3672. </div>
  3673. </div>
  3674. )}
  3675. </div>
  3676. )}
  3677. {/* 收缩状态下的展开按钮 */}
  3678. {rightPanelCollapsed && (
  3679. <div className="w-12 bg-gray-50 border-l border-gray-200 flex items-center justify-center">
  3680. <Tooltip title="展开面板" placement="left">
  3681. <button
  3682. onClick={() => setRightPanelCollapsed(false)}
  3683. className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
  3684. >
  3685. <MenuUnfoldOutlined className="text-gray-500 text-lg" />
  3686. </button>
  3687. </Tooltip>
  3688. </div>
  3689. )}
  3690. </div>
  3691. <Modal
  3692. open={exportVisible}
  3693. title="流程 JSON 导出"
  3694. onCancel={() => setExportVisible(false)}
  3695. onOk={() => setExportVisible(false)}
  3696. width={800}
  3697. okText="关闭"
  3698. cancelText="取消"
  3699. style={{ top: 20 }}
  3700. styles={{ body: { maxHeight: 'calc(90vh - 120px)', overflowY: 'auto', overflowX: 'hidden', padding: 0 } }}
  3701. >
  3702. <div className="relative">
  3703. {/* 固定在顶部的复制按钮 */}
  3704. <div className="sticky top-0 bg-white z-10 px-6 py-3 flex justify-end">
  3705. <Button
  3706. type="default"
  3707. icon={<CopyOutlined />}
  3708. onClick={handleCopyJson}
  3709. >
  3710. 复制JSON
  3711. </Button>
  3712. </div>
  3713. {/* JSON内容区域 */}
  3714. <div className="p-6">
  3715. <div className="bg-gray-50 border border-gray-200 rounded-lg p-3 overflow-x-hidden">
  3716. <pre className="text-xs text-gray-800 whitespace-pre-wrap break-words font-mono overflow-x-hidden">
  3717. {exportContent}
  3718. </pre>
  3719. </div>
  3720. </div>
  3721. </div>
  3722. </Modal>
  3723. <Modal
  3724. open={importVisible}
  3725. title="导入流程 JSON"
  3726. onCancel={() => {
  3727. setImportVisible(false);
  3728. setImportJson('');
  3729. }}
  3730. onOk={handleImportJson}
  3731. width={800}
  3732. okText="导入"
  3733. cancelText="取消"
  3734. >
  3735. <div className="space-y-3">
  3736. <p className="text-sm text-gray-600">
  3737. 请粘贴流程 JSON 数据,导入后将替换当前画布内容
  3738. </p>
  3739. <Input.TextArea
  3740. value={importJson}
  3741. onChange={(e) => setImportJson(e.target.value)}
  3742. placeholder="请粘贴 JSON 数据..."
  3743. rows={15}
  3744. className="font-mono text-xs"
  3745. />
  3746. </div>
  3747. </Modal>
  3748. {/* 表单预览Modal */}
  3749. <Modal
  3750. open={formPreviewVisible}
  3751. title={`预览表单 - ${formPreviewData?.name || ''}`}
  3752. onCancel={() => {
  3753. setFormPreviewVisible(false);
  3754. setFormPreviewData(null);
  3755. setFormPreviewDetailData({ rule: [], option: {} });
  3756. }}
  3757. footer={[
  3758. <Button key="close" onClick={() => {
  3759. setFormPreviewVisible(false);
  3760. setFormPreviewData(null);
  3761. setFormPreviewDetailData({ rule: [], option: {} });
  3762. }}>
  3763. 关闭
  3764. </Button>
  3765. ]}
  3766. width={800}
  3767. style={{ top: 20 }}
  3768. styles={{ body: { maxHeight: 'calc(90vh - 120px)', overflowY: 'auto', overflowX: 'hidden' } }}
  3769. >
  3770. <div className="p-4">
  3771. {(() => {
  3772. const formConfig = formPreviewDetailData.option?.formConfig || defaultFormConfig;
  3773. const layoutColumns = formConfig.layoutColumns || 1;
  3774. // 渲染字段预览(支持嵌套结构)
  3775. const renderFieldPreview = (field: any, parentSpanStyle?: React.CSSProperties): React.ReactNode => {
  3776. const spanStyle = parentSpanStyle || (layoutColumns > 1 ? { gridColumn: `span ${Math.min(layoutColumns, field.span || 1)}` } : undefined);
  3777. // 处理容器类型(card 和 grid)
  3778. if (field.type === 'card') {
  3779. const children = field.children || [];
  3780. // 优先使用 label(字段名称),如果没有则使用 cardTitle,最后使用默认值
  3781. const cardTitle = field.label || field.cardTitle || '卡片容器';
  3782. return (
  3783. <div key={field.id} style={spanStyle} className="mb-4">
  3784. <Card title={cardTitle} className="w-full">
  3785. <div className="space-y-4">
  3786. {children.map((child: any) => renderFieldPreview(child))}
  3787. </div>
  3788. </Card>
  3789. </div>
  3790. );
  3791. }
  3792. if (field.type === 'grid') {
  3793. const gridColumns = field.gridColumns || 2;
  3794. const children = field.children || [];
  3795. return (
  3796. <div key={field.id} style={spanStyle} className="mb-4">
  3797. <div
  3798. style={{
  3799. display: 'grid',
  3800. gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
  3801. gap: '16px',
  3802. }}
  3803. >
  3804. {children.map((child: any) => {
  3805. const childSpanStyle = gridColumns > 1
  3806. ? { gridColumn: `span ${Math.min(gridColumns, child.span || 1)}` }
  3807. : undefined;
  3808. return (
  3809. <div key={child.id} style={childSpanStyle}>
  3810. {renderFieldPreview(child)}
  3811. </div>
  3812. );
  3813. })}
  3814. </div>
  3815. </div>
  3816. );
  3817. }
  3818. // 处理 alert 类型
  3819. if (field.type === 'alert') {
  3820. return (
  3821. <div key={field.id} className="mb-4" style={spanStyle}>
  3822. <Alert
  3823. message={field.alertTitle || field.label}
  3824. description={field.alertDescription}
  3825. type={field.alertType || 'info'}
  3826. showIcon={field.alertShowIcon !== false}
  3827. closable={field.alertClosable}
  3828. />
  3829. </div>
  3830. );
  3831. }
  3832. // 处理普通字段
  3833. switch (field.type) {
  3834. case 'textarea':
  3835. return (
  3836. <div key={field.id} style={spanStyle} className="form-item-wrapper">
  3837. <AntdForm.Item
  3838. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  3839. name={field.name || field.field}
  3840. required={field.required && !formConfig.hideRequiredMark}
  3841. help={field.hint}
  3842. >
  3843. <Input.TextArea
  3844. placeholder={typeof field.placeholder === 'string' ? field.placeholder : '请输入'}
  3845. rows={4}
  3846. />
  3847. </AntdForm.Item>
  3848. </div>
  3849. );
  3850. case 'number':
  3851. return (
  3852. <div key={field.id} style={spanStyle} className="form-item-wrapper">
  3853. <AntdForm.Item
  3854. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  3855. name={field.name || field.field}
  3856. required={field.required && !formConfig.hideRequiredMark}
  3857. help={field.hint}
  3858. >
  3859. <InputNumber style={{ width: '100%' }} placeholder={typeof field.placeholder === 'string' ? field.placeholder : '请输入'} />
  3860. </AntdForm.Item>
  3861. </div>
  3862. );
  3863. case 'select':
  3864. return (
  3865. <div key={field.id} style={spanStyle} className="form-item-wrapper">
  3866. <AntdForm.Item
  3867. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  3868. name={field.name || field.field}
  3869. required={field.required && !formConfig.hideRequiredMark}
  3870. help={field.hint}
  3871. >
  3872. <Select placeholder={typeof field.placeholder === 'string' ? field.placeholder : '请选择'}>
  3873. {(field.options || []).map((opt: any, idx: number) => (
  3874. <Select.Option key={idx} value={opt.value}>{opt.label}</Select.Option>
  3875. ))}
  3876. </Select>
  3877. </AntdForm.Item>
  3878. </div>
  3879. );
  3880. case 'date':
  3881. return (
  3882. <div key={field.id} style={spanStyle} className="form-item-wrapper">
  3883. <AntdForm.Item
  3884. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  3885. name={field.name || field.field}
  3886. required={field.required && !formConfig.hideRequiredMark}
  3887. help={field.hint}
  3888. >
  3889. <DatePicker style={{ width: '100%' }} placeholder={typeof field.placeholder === 'string' ? field.placeholder : '请选择日期'} />
  3890. </AntdForm.Item>
  3891. </div>
  3892. );
  3893. case 'switch':
  3894. return (
  3895. <div key={field.id} style={spanStyle} className="form-item-wrapper">
  3896. <AntdForm.Item
  3897. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  3898. name={field.name || field.field}
  3899. valuePropName="checked"
  3900. >
  3901. <Switch />
  3902. </AntdForm.Item>
  3903. </div>
  3904. );
  3905. case 'radio':
  3906. return (
  3907. <div key={field.id} style={spanStyle} className="form-item-wrapper">
  3908. <AntdForm.Item
  3909. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  3910. name={field.name || field.field}
  3911. required={field.required && !formConfig.hideRequiredMark}
  3912. help={field.hint}
  3913. >
  3914. <Radio.Group>
  3915. {(field.options || []).map((opt: any, idx: number) => (
  3916. <Radio key={idx} value={opt.value}>{opt.label}</Radio>
  3917. ))}
  3918. </Radio.Group>
  3919. </AntdForm.Item>
  3920. </div>
  3921. );
  3922. case 'checkbox':
  3923. return (
  3924. <div key={field.id} style={spanStyle} className="form-item-wrapper">
  3925. <AntdForm.Item
  3926. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  3927. name={field.name || field.field}
  3928. required={field.required && !formConfig.hideRequiredMark}
  3929. help={field.hint}
  3930. >
  3931. <Checkbox.Group>
  3932. {(field.options || []).map((opt: any, idx: number) => (
  3933. <Checkbox key={idx} value={opt.value}>{opt.label}</Checkbox>
  3934. ))}
  3935. </Checkbox.Group>
  3936. </AntdForm.Item>
  3937. </div>
  3938. );
  3939. default:
  3940. return (
  3941. <div key={field.id} style={spanStyle} className="form-item-wrapper">
  3942. <AntdForm.Item
  3943. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  3944. name={field.name || field.field}
  3945. required={field.required && !formConfig.hideRequiredMark}
  3946. help={field.hint}
  3947. >
  3948. <Input
  3949. type={field.inputType || 'text'}
  3950. placeholder={typeof field.placeholder === 'string' ? field.placeholder : '请输入'}
  3951. />
  3952. </AntdForm.Item>
  3953. </div>
  3954. );
  3955. }
  3956. };
  3957. // 预览模式下,强制使用更小的 label 宽度,让输入区域更宽
  3958. const previewLabelSpan = formConfig.labelPosition !== 'top'
  3959. ? (formConfig.labelWidth ? Math.min(Math.floor(formConfig.labelWidth / 8), 4) : 4)
  3960. : undefined;
  3961. const previewWrapperSpan = previewLabelSpan ? 24 - previewLabelSpan : undefined;
  3962. return (
  3963. <>
  3964. <style>{`
  3965. .form-preview-modal .ant-form-item-label {
  3966. flex: 0 0 auto !important;
  3967. max-width: none !important;
  3968. }
  3969. .form-preview-modal .ant-form-item-label > label {
  3970. font-size: 14px !important;
  3971. white-space: nowrap !important;
  3972. overflow: hidden !important;
  3973. text-overflow: ellipsis !important;
  3974. }
  3975. .form-preview-modal .ant-form-item-control {
  3976. flex: 1 1 auto !important;
  3977. min-width: 0 !important;
  3978. }
  3979. .form-preview-modal .form-item-wrapper {
  3980. background-color: #f5f5f5;
  3981. border-radius: 8px;
  3982. padding: 12px 16px;
  3983. margin-bottom: 16px;
  3984. }
  3985. .form-preview-modal .form-item-wrapper .ant-form-item {
  3986. margin-bottom: 0;
  3987. }
  3988. `}</style>
  3989. <AntdForm
  3990. form={formPreviewForm}
  3991. layout={formConfig.labelPosition === 'top' ? 'vertical' : 'horizontal'}
  3992. size={formConfig.formSize || 'middle'}
  3993. labelCol={previewLabelSpan ? { span: previewLabelSpan } : undefined}
  3994. wrapperCol={previewWrapperSpan ? { span: previewWrapperSpan } : undefined}
  3995. className="form-preview-modal"
  3996. >
  3997. <div style={layoutColumns > 1 ? { display: 'grid', gridTemplateColumns: `repeat(${layoutColumns}, minmax(0, 1fr))`, gap: '16px' } : {}}>
  3998. {(formPreviewDetailData.rule || []).map((field: any) => renderFieldPreview(field))}
  3999. </div>
  4000. </AntdForm>
  4001. </>
  4002. );
  4003. })()}
  4004. </div>
  4005. </Modal>
  4006. </div>
  4007. );
  4008. }