2026-07-20-demo-P1-shell.html 154 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  6. <title>申勤物业 · 数字化平台</title>
  7. <style>
  8. :root {
  9. --bg: #f4f6f8;
  10. --surface: #fff;
  11. --border: #e8ecef;
  12. --text: #1a2b3c;
  13. --text2: #5c6f82;
  14. --text3: #8a9bab;
  15. --primary: #0d9488;
  16. --primary-soft: #e6f6f4;
  17. --warn: #d97706;
  18. --danger: #dc2626;
  19. --radius: 10px;
  20. --font: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
  21. }
  22. * { box-sizing: border-box; margin: 0; padding: 0; }
  23. body { font-family: var(--font); font-size: 14px; color: var(--text); background: var(--bg); line-height: 1.5; }
  24. a { color: var(--primary); text-decoration: none; cursor: pointer; }
  25. a:hover { text-decoration: underline; }
  26. .hidden { display: none !important; }
  27. /* layout */
  28. .layout { display: flex; min-height: 100vh; }
  29. .sidebar {
  30. width: 220px; flex-shrink: 0; background: var(--surface);
  31. border-right: 1px solid var(--border); padding: 16px 12px;
  32. display: flex; flex-direction: column;
  33. }
  34. .brand { display: flex; align-items: center; gap: 10px; padding: 8px 10px 20px; }
  35. .brand-icon {
  36. width: 36px; height: 36px; background: var(--primary); border-radius: 8px;
  37. color: #fff; display: flex; align-items: center; justify-content: center;
  38. font-weight: 700; font-size: 14px;
  39. }
  40. .brand strong { display: block; font-size: 14px; }
  41. .brand span { font-size: 11px; color: var(--text3); }
  42. .nav a {
  43. display: block; padding: 8px 12px; border-radius: 6px; color: var(--text2);
  44. font-size: 13px; margin-bottom: 2px;
  45. }
  46. .nav a:hover { background: #f8fafb; color: var(--text); text-decoration: none; }
  47. .nav a.active { background: var(--primary-soft); color: var(--primary); font-weight: 500; }
  48. .nav-label { font-size: 11px; color: var(--text3); padding: 12px 12px 6px; font-weight: 600; }
  49. .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
  50. .topbar {
  51. min-height: 52px; background: var(--surface); border-bottom: 1px solid var(--border);
  52. display: flex; align-items: center; justify-content: space-between;
  53. padding: 10px 24px; gap: 16px; flex-wrap: wrap;
  54. }
  55. .topbar h1 { font-size: 17px; font-weight: 600; }
  56. .topbar p { font-size: 12px; color: var(--text3); }
  57. .content { padding: 20px 24px 32px; flex: 1; }
  58. /* components */
  59. .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 16px; }
  60. .card-head { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 14px; }
  61. .card-body { padding: 16px; }
  62. .stats { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 20px; }
  63. .stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; }
  64. .stat label { font-size: 12px; color: var(--text3); display: block; margin-bottom: 4px; }
  65. .stat b { font-size: 22px; font-weight: 600; }
  66. .stat small { font-size: 11px; color: var(--text2); display: block; margin-top: 4px; }
  67. .stat.warn b { color: var(--danger); }
  68. .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
  69. table { width: 100%; border-collapse: collapse; font-size: 13px; }
  70. th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); }
  71. th { color: var(--text3); font-size: 12px; font-weight: 600; }
  72. .tag { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: #ecfdf5; color: #059669; }
  73. .tag-warn { background: #fff7ed; color: var(--warn); }
  74. .tag-danger { background: #fef2f2; color: var(--danger); }
  75. .btn {
  76. display: inline-block; padding: 7px 14px; border-radius: 6px; font-size: 13px;
  77. font-weight: 500; border: 1px solid var(--border); background: #fff;
  78. color: var(--text2); cursor: pointer; font-family: inherit;
  79. }
  80. .btn:hover { background: #f8fafb; }
  81. .btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
  82. .btn-primary:hover { background: #0b7d73; }
  83. .btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
  84. .btn-ghost { border-color: transparent; background: transparent; }
  85. .proto-note {
  86. background: #fffbeb; border: 1px solid #fde68a; color: #92400e;
  87. padding: 10px 14px; border-radius: 8px; font-size: 12px; margin-bottom: 16px;
  88. }
  89. .banner-renewal {
  90. background: linear-gradient(90deg, #fff7ed, #fff);
  91. border: 1px solid #fed7aa; border-left: 4px solid var(--warn);
  92. padding: 12px 16px; border-radius: var(--radius); margin-bottom: 16px;
  93. display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
  94. }
  95. .banner-renewal.urgent {
  96. background: linear-gradient(90deg, #fef2f2, #fff);
  97. border-color: #fecaca; border-left-color: var(--danger);
  98. }
  99. .banner-renewal strong { color: var(--danger); }
  100. .todo-list { list-style: none; }
  101. .todo-item { display: flex; align-items: flex-start; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); }
  102. .todo-item:last-child { border-bottom: none; }
  103. .todo-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--primary); margin-top: 6px; flex-shrink: 0; }
  104. .todo-dot.urgent { background: var(--danger); animation: pulse 1.5s infinite; }
  105. @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
  106. .quick-links { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  107. .quick-card {
  108. display: block; padding: 14px; border: 1px solid var(--border); border-radius: 8px;
  109. background: #fafbfc; text-decoration: none; color: inherit;
  110. }
  111. .quick-card:hover { border-color: var(--primary); background: var(--primary-soft); text-decoration: none; }
  112. .quick-card strong { display: block; font-size: 14px; margin-bottom: 4px; }
  113. .quick-card span { font-size: 12px; color: var(--text3); }
  114. .quick-card--primary { border-color: var(--primary); background: var(--primary-soft); }
  115. .stage-bar {
  116. display: flex; gap: 6px; align-items: center; padding: 12px 16px;
  117. background: #fff; border: 1px solid var(--border); border-radius: 10px; margin-bottom: 16px;
  118. }
  119. .dot { width: 10px; height: 10px; border-radius: 50%; background: #e8ecef; }
  120. .dot.done { background: #0d9488; }
  121. .dot.cur { background: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2); }
  122. .info { display: grid; grid-template-columns: 100px 1fr; gap: 8px 12px; font-size: 13px; }
  123. .info dt { color: var(--text3); }
  124. .info-wide { grid-template-columns: minmax(140px, 168px) 1fr; }
  125. .card-note { font-size: 11px; color: var(--text3); font-weight: 400; margin-left: 8px; }
  126. .checklist { list-style: none; }
  127. .checklist li { padding: 8px 0; border-bottom: 1px dashed var(--border); font-size: 13px; display: flex; gap: 8px; }
  128. .checklist li.done { color: var(--text3); text-decoration: line-through; }
  129. /* GIS full page */
  130. .view-gis { position: fixed; inset: 0; z-index: 100; background: #e8ecef; }
  131. .gis-top {
  132. position: absolute; top: 0; left: 0; right: 0; z-index: 10; height: 48px;
  133. background: rgba(255, 255, 255, 0.95); border-bottom: 1px solid var(--border);
  134. display: flex; align-items: center; gap: 12px; padding: 0 16px 0 280px;
  135. }
  136. .gis-top h1 { font-size: 14px; font-weight: 600; }
  137. .gis-back { font-size: 12px; color: var(--text2); }
  138. .gis-left {
  139. position: absolute; top: 56px; left: 12px; bottom: 40px; width: 268px; z-index: 10;
  140. background: rgba(255, 255, 255, 0.96); border: 1px solid var(--border); border-radius: 10px;
  141. display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
  142. }
  143. .gis-left .search { padding: 10px; border-bottom: 1px solid var(--border); }
  144. .gis-left .search input { width: 100%; padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px; }
  145. .gis-left .count { padding: 6px 12px; font-size: 11px; color: var(--text3); }
  146. .gis-list { flex: 1; overflow-y: auto; padding: 0 8px 10px; font-size: 12px; }
  147. .gis-item {
  148. display: block; width: 100%; text-align: left; padding: 8px 10px; border: none;
  149. border-radius: 6px; background: transparent; cursor: pointer; font-family: inherit;
  150. margin-bottom: 2px;
  151. }
  152. .gis-item:hover, .gis-item.on { background: var(--primary-soft); }
  153. .gis-item b { display: block; font-size: 13px; color: var(--text); }
  154. .gis-item span { color: var(--text3); font-size: 11px; }
  155. .gis-item .renew-badge { font-size: 10px; color: var(--danger); font-weight: 600; }
  156. .map-mock {
  157. position: absolute; inset: 0;
  158. background: linear-gradient(180deg, #f0f4f8 0%, #e2e8ec 100%);
  159. background-image:
  160. linear-gradient(rgba(255, 255, 255, 0.6) 1px, transparent 1px),
  161. linear-gradient(90deg, rgba(255, 255, 255, 0.6) 1px, transparent 1px);
  162. background-size: 48px 48px;
  163. }
  164. .map-road {
  165. position: absolute; background: #fff; border: 1px solid #d5dde3; border-radius: 4px;
  166. }
  167. .map-road.r1 { left: 15%; top: 35%; width: 55%; height: 12px; transform: rotate(-8deg); }
  168. .map-road.r2 { left: 40%; top: 20%; width: 8px; height: 50%; }
  169. .pin-project {
  170. position: absolute; width: 16px; height: 16px; background: #0d9488;
  171. border: 2px solid #fff; border-radius: 50%; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
  172. z-index: 2; cursor: pointer; transform: translate(-50%, -50%);
  173. }
  174. .pin-project.renew { background: var(--danger); animation: pin-pulse 2s infinite; }
  175. @keyframes pin-pulse {
  176. 0% { box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.5); }
  177. 70% { box-shadow: 0 0 0 12px rgba(220, 38, 38, 0); }
  178. 100% { box-shadow: 0 0 0 0 rgba(220, 38, 38, 0); }
  179. }
  180. .zone { position: absolute; border-radius: 8px; pointer-events: none; }
  181. .zone.renew { background: rgba(220, 38, 38, 0.15); border: 2px dashed var(--danger); }
  182. .gis-tools {
  183. position: absolute; top: 64px; right: 12px; z-index: 10;
  184. background: #fff; border: 1px solid var(--border); border-radius: 10px; padding: 8px;
  185. font-size: 11px; color: var(--text2); min-width: 120px;
  186. }
  187. .gis-tools label { display: flex; align-items: center; gap: 6px; padding: 4px 0; cursor: pointer; }
  188. .gis-info-card {
  189. position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 10;
  190. width: min(420px, 90vw); background: #fff; border: 1px solid var(--border);
  191. border-radius: 12px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); padding: 16px;
  192. }
  193. .gis-info-card header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
  194. .gis-info-card h3 { font-size: 15px; }
  195. .gis-info-card dl { display: grid; grid-template-columns: 90px 1fr; gap: 6px 10px; font-size: 12px; margin-bottom: 12px; }
  196. .gis-info-card dt { color: var(--text3); }
  197. .gis-info-card .actions { display: flex; gap: 8px; flex-wrap: wrap; }
  198. /* bid workbench */
  199. .steps { display: flex; gap: 4px; margin-bottom: 20px; flex-wrap: wrap; }
  200. .step {
  201. padding: 6px 14px; border-radius: 20px; font-size: 12px;
  202. background: #f1f5f9; color: var(--text3);
  203. }
  204. .step.active { background: var(--primary-soft); color: var(--primary); font-weight: 600; }
  205. .step.done { background: #ecfdf5; color: #059669; }
  206. .bid-task-row {
  207. display: flex; align-items: center; justify-content: space-between;
  208. padding: 14px 16px; border-bottom: 1px solid var(--border); gap: 12px;
  209. }
  210. .bid-task-row:last-child { border-bottom: none; }
  211. .bid-workspace {
  212. display: grid;
  213. grid-template-columns: minmax(200px, 0.85fr) minmax(240px, 1fr) minmax(200px, 0.85fr);
  214. grid-template-rows: minmax(240px, 36vh) minmax(38vh, 1fr);
  215. gap: 12px;
  216. flex: 1;
  217. min-height: calc(100vh - 240px);
  218. }
  219. .bid-col {
  220. background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  221. display: flex; flex-direction: column; overflow: hidden; min-height: 0;
  222. }
  223. .bid-col-head {
  224. padding: 10px 14px; border-bottom: 1px solid var(--border);
  225. font-weight: 600; font-size: 13px; background: #fafbfc; flex-shrink: 0;
  226. }
  227. .bid-col-body { padding: 12px 14px; flex: 1; overflow-y: auto; font-size: 13px; min-height: 0; }
  228. .bid-blocks-compact { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
  229. .block-item {
  230. border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; cursor: pointer;
  231. transition: border-color 0.12s, background 0.12s;
  232. }
  233. .block-item.compact { padding: 8px 10px; margin-bottom: 0; }
  234. .block-item.compact strong { font-size: 13px; }
  235. .block-item.active { border-color: var(--primary); background: var(--primary-soft); }
  236. .block-meta { font-size: 11px; color: var(--text3); margin-top: 4px; }
  237. .bid-editor-zone {
  238. grid-column: 1 / -1;
  239. background: var(--surface);
  240. border: 2px solid var(--primary);
  241. border-radius: var(--radius);
  242. display: flex;
  243. flex-direction: column;
  244. overflow: hidden;
  245. min-height: 38vh;
  246. }
  247. .bid-editor-zone.readonly { border-color: var(--border); }
  248. .bid-editor-zone:focus-within { box-shadow: 0 0 0 4px var(--primary-soft); }
  249. .bid-editor-toolbar {
  250. display: flex; justify-content: space-between; align-items: center; gap: 12px;
  251. padding: 12px 16px; background: linear-gradient(180deg, #fafbfc, #fff);
  252. border-bottom: 1px solid var(--border); flex-shrink: 0; flex-wrap: wrap;
  253. }
  254. .bid-editor-toolbar-left { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-width: 0; }
  255. .bid-editor-toolbar-left h3 { font-size: 15px; font-weight: 600; margin: 0; }
  256. .bid-editor-toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
  257. .bid-editor-body { flex: 1; display: flex; flex-direction: column; min-height: 0; background: #fff; }
  258. .bid-draft-editor {
  259. flex: 1;
  260. width: 100%;
  261. min-height: calc(38vh - 108px);
  262. padding: 20px 24px;
  263. border: none;
  264. border-radius: 0;
  265. font-family: inherit;
  266. font-size: 14px;
  267. line-height: 1.85;
  268. color: var(--text);
  269. resize: none;
  270. outline: none;
  271. background: #fff;
  272. tab-size: 4;
  273. }
  274. .bid-draft-editor::placeholder { color: var(--text3); }
  275. .bid-draft-readonly {
  276. flex: 1;
  277. min-height: calc(38vh - 108px);
  278. padding: 20px 24px;
  279. font-size: 14px;
  280. line-height: 1.85;
  281. color: var(--text2);
  282. white-space: pre-wrap;
  283. overflow-y: auto;
  284. background: #fafbfc;
  285. }
  286. .bid-editor-foot {
  287. padding: 8px 16px;
  288. border-top: 1px solid var(--border);
  289. font-size: 11px;
  290. color: var(--text3);
  291. display: flex;
  292. justify-content: space-between;
  293. align-items: center;
  294. gap: 12px;
  295. flex-shrink: 0;
  296. background: #fafbfc;
  297. }
  298. .bid-ref-scroll { max-height: 100%; overflow-y: auto; }
  299. .mapping-table.compact { font-size: 11px; }
  300. .mapping-table.compact td, .mapping-table.compact th { padding: 5px 6px; }
  301. #view-bid-task .content.bid-task-content {
  302. display: flex;
  303. flex-direction: column;
  304. min-height: 0;
  305. padding-bottom: 16px;
  306. }
  307. .bid-three-col {
  308. display: grid; grid-template-columns: 1fr 1.2fr 1fr; gap: 12px; min-height: 480px;
  309. }
  310. .fill-auto { color: #059669; }
  311. .fill-confirm { color: var(--warn); }
  312. .fill-manual { color: #dc2626; }
  313. .doc-line { padding: 6px 8px; border-radius: 4px; margin-bottom: 4px; cursor: pointer; font-size: 12px; }
  314. .doc-line:hover { background: #f1f5f9; }
  315. .doc-line.highlight { background: #fff7ed; border-left: 3px solid var(--warn); }
  316. .preview-doc { font-size: 12px; line-height: 1.7; color: var(--text2); }
  317. .preview-doc h4 { color: var(--text); font-size: 14px; margin: 12px 0 6px; }
  318. .preview-doc h4:first-child { margin-top: 0; }
  319. .empty-shell {
  320. text-align: center; padding: 48px 24px; color: var(--text3);
  321. border: 2px dashed var(--border); border-radius: var(--radius);
  322. }
  323. .empty-shell p { margin-top: 8px; font-size: 13px; }
  324. /* tender pool */
  325. .market-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); padding-bottom: 0; }
  326. .market-tab {
  327. padding: 10px 18px; border: none; background: none; font-family: inherit;
  328. font-size: 13px; color: var(--text2); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px;
  329. }
  330. .market-tab.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 600; }
  331. .pool-toolbar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
  332. .pool-toolbar input, .pool-toolbar select {
  333. padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px;
  334. }
  335. .pool-chips { display: flex; gap: 6px; flex-wrap: wrap; }
  336. .chip {
  337. padding: 5px 12px; border-radius: 16px; font-size: 12px; border: 1px solid var(--border);
  338. background: #fff; cursor: pointer; font-family: inherit; color: var(--text2);
  339. }
  340. .chip.active { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); font-weight: 500; }
  341. .pool-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 16px; }
  342. .pool-stat {
  343. background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
  344. padding: 12px 14px; cursor: pointer; transition: border-color 0.15s;
  345. }
  346. .pool-stat:hover { border-color: var(--primary); }
  347. .pool-stat b { display: block; font-size: 20px; margin-top: 2px; }
  348. .pool-stat span { font-size: 11px; color: var(--text3); }
  349. .pool-stat.highlight { border-color: var(--danger); background: #fef2f2; }
  350. .tender-row {
  351. display: grid; grid-template-columns: 1fr auto; gap: 12px; padding: 14px 16px;
  352. border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.12s;
  353. }
  354. .tender-row:hover { background: #f8fafb; }
  355. .tender-row.selected { background: var(--primary-soft); }
  356. .tender-row:last-child { border-bottom: none; }
  357. .tender-title { font-weight: 600; font-size: 14px; margin-bottom: 6px; }
  358. .tender-meta { font-size: 12px; color: var(--text3); display: flex; flex-wrap: wrap; gap: 12px; }
  359. .tender-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
  360. .score-badge {
  361. font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 600;
  362. background: #ecfdf5; color: #059669;
  363. }
  364. .score-badge.high { background: #fef2f2; color: var(--danger); }
  365. .tag-renewal { background: #fef2f2; color: var(--danger); }
  366. .tag-new { background: #eff6ff; color: #2563eb; }
  367. /* drawer & toast */
  368. .drawer-mask {
  369. position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 200;
  370. display: flex; justify-content: flex-end;
  371. }
  372. .drawer {
  373. width: min(480px, 95vw); background: #fff; height: 100%; overflow-y: auto;
  374. box-shadow: -4px 0 24px rgba(0,0,0,0.12); animation: slideIn 0.2s ease;
  375. }
  376. @keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
  377. .drawer-head { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
  378. .drawer-body { padding: 16px 20px; }
  379. .drawer-section { margin-bottom: 20px; }
  380. .drawer-section h4 { font-size: 13px; color: var(--text3); margin-bottom: 8px; font-weight: 600; }
  381. .source-link-box {
  382. display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px;
  383. background: #f8fafb; border: 1px solid var(--border); border-radius: 8px; margin-top: 8px;
  384. }
  385. .source-link-box a { word-break: break-all; font-size: 12px; line-height: 1.5; }
  386. .toast-wrap { position: fixed; bottom: 24px; right: 24px; z-index: 300; display: flex; flex-direction: column; gap: 8px; }
  387. .toast {
  388. background: #1a2b3c; color: #fff; padding: 10px 16px; border-radius: 8px;
  389. font-size: 13px; box-shadow: 0 4px 16px rgba(0,0,0,0.2); animation: fadeUp 0.25s ease;
  390. max-width: 360px;
  391. }
  392. @keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
  393. .progress-bar { height: 6px; background: #e8ecef; border-radius: 3px; overflow: hidden; margin: 12px 0; }
  394. .progress-bar i { display: block; height: 100%; background: var(--primary); border-radius: 3px; transition: width 0.3s; }
  395. .checklist li.clickable { cursor: pointer; }
  396. .checklist li.clickable:hover { background: #f8fafb; margin: 0 -8px; padding-left: 8px; padding-right: 8px; border-radius: 4px; }
  397. .stat.clickable { cursor: pointer; }
  398. .stat.clickable:hover { border-color: var(--primary); }
  399. .block-item.confirmed { border-color: #059669; background: #ecfdf5; }
  400. .block-item.confirmed .block-meta::after { content: ' · 已确认'; color: #059669; font-weight: 600; }
  401. .doc-line.active { background: var(--primary-soft); border-left: 3px solid var(--primary); }
  402. .flow-mini { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text3); margin-top: 12px; flex-wrap: wrap; }
  403. .flow-mini span.done { color: #059669; }
  404. .flow-mini span.cur { color: var(--primary); font-weight: 600; }
  405. /* bid result & push */
  406. .form-grid { display: grid; grid-template-columns: 120px 1fr; gap: 10px 14px; align-items: center; font-size: 13px; max-width: 640px; }
  407. .form-grid label { color: var(--text2); }
  408. .form-grid input, .form-grid select, .form-grid textarea {
  409. padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; width: 100%;
  410. }
  411. .form-grid textarea { min-height: 60px; resize: vertical; }
  412. .monitor-card {
  413. border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px;
  414. background: #fff; transition: border-color 0.15s;
  415. }
  416. .monitor-card.active { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft); }
  417. .monitor-card.won { border-color: #059669; background: #f0fdf4; }
  418. .monitor-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
  419. .monitor-meta { font-size: 12px; color: var(--text3); display: flex; flex-wrap: wrap; gap: 14px; margin-bottom: 10px; }
  420. .monitor-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
  421. .monitor-status .pulse-dot {
  422. width: 8px; height: 8px; border-radius: 50%; background: var(--primary); animation: pulse 1.5s infinite;
  423. }
  424. .push-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 14px; }
  425. .push-item {
  426. border: 1px solid var(--border); border-radius: 8px; padding: 12px; text-align: center; font-size: 12px;
  427. background: #fafbfc;
  428. }
  429. .push-item.done { border-color: #059669; background: #ecfdf5; }
  430. .push-item strong { display: block; font-size: 13px; margin-bottom: 4px; }
  431. .timeline { list-style: none; margin-top: 12px; }
  432. .timeline li { padding: 8px 0 8px 16px; border-left: 2px solid var(--border); margin-left: 6px; font-size: 12px; color: var(--text2); position: relative; }
  433. .timeline li::before {
  434. content: ''; position: absolute; left: -5px; top: 12px; width: 8px; height: 8px;
  435. border-radius: 50%; background: var(--primary);
  436. }
  437. .timeline li.done::before { background: #059669; }
  438. .result-banner {
  439. background: linear-gradient(90deg, #ecfdf5, #fff); border: 1px solid #a7f3d0; border-left: 4px solid #059669;
  440. padding: 14px 16px; border-radius: var(--radius); margin-bottom: 16px;
  441. }
  442. .result-banner.lost { background: linear-gradient(90deg, #fef2f2, #fff); border-color: #fecaca; border-left-color: var(--danger); }
  443. .page-tabs { display: flex; gap: 0; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
  444. .page-tab {
  445. padding: 10px 18px; border: none; background: none; font-family: inherit; font-size: 13px;
  446. color: var(--text2); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px;
  447. }
  448. .page-tab.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 600; }
  449. /* renewal hub */
  450. .renewal-hero {
  451. background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%);
  452. color: #fff; border-radius: var(--radius); padding: 20px 24px; margin-bottom: 16px;
  453. display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; flex-wrap: wrap;
  454. }
  455. .renewal-hero h2 { font-size: 20px; margin-bottom: 4px; }
  456. .renewal-hero p { font-size: 13px; opacity: 0.9; }
  457. .renewal-countdown {
  458. text-align: center; background: rgba(255,255,255,0.15); border-radius: 10px;
  459. padding: 12px 20px; min-width: 100px;
  460. }
  461. .renewal-countdown b { display: block; font-size: 32px; line-height: 1; }
  462. .renewal-countdown span { font-size: 11px; opacity: 0.85; }
  463. .renewal-stepper {
  464. display: flex; align-items: center; gap: 0; margin-bottom: 20px; overflow-x: auto;
  465. padding: 12px 16px; background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
  466. }
  467. .renewal-step {
  468. display: flex; align-items: center; flex-shrink: 0; font-size: 12px; color: var(--text3);
  469. }
  470. .renewal-step-dot {
  471. width: 28px; height: 28px; border-radius: 50%; background: #e8ecef; color: var(--text3);
  472. display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 11px;
  473. margin-right: 6px; flex-shrink: 0;
  474. }
  475. .renewal-step.done .renewal-step-dot { background: #059669; color: #fff; }
  476. .renewal-step.active .renewal-step-dot { background: var(--primary); color: #fff; box-shadow: 0 0 0 3px var(--primary-soft); }
  477. .renewal-step.active { color: var(--primary); font-weight: 600; }
  478. .renewal-step-arrow { color: var(--border); margin: 0 8px; font-size: 14px; }
  479. .renewal-layout { display: grid; grid-template-columns: 1fr 340px; gap: 16px; align-items: start; }
  480. .action-card {
  481. border: 2px solid var(--primary); border-radius: var(--radius); background: var(--primary-soft);
  482. padding: 18px 20px; margin-bottom: 16px;
  483. }
  484. .action-card h3 { font-size: 16px; margin-bottom: 8px; color: var(--text); }
  485. .action-card p { font-size: 13px; color: var(--text2); margin-bottom: 14px; }
  486. .action-card .actions { display: flex; gap: 8px; flex-wrap: wrap; }
  487. .side-card { background: #fff; border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 12px; }
  488. .side-card-head { padding: 10px 14px; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 13px; }
  489. .side-card-body { padding: 12px 14px; font-size: 13px; }
  490. .tender-mini {
  491. border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-top: 8px;
  492. background: #fafbfc;
  493. }
  494. .tender-mini.matched { border-color: var(--danger); background: #fef2f2; }
  495. .tender-mini.monitoring { border-color: var(--warn); background: #fffbeb; }
  496. /* renewal list */
  497. .renewal-row {
  498. display: grid; grid-template-columns: 1fr auto; gap: 12px; padding: 14px 16px;
  499. border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.12s;
  500. }
  501. .renewal-row:hover { background: #f8fafb; }
  502. .renewal-row:last-child { border-bottom: none; }
  503. .renewal-row-title { font-weight: 600; font-size: 14px; margin-bottom: 6px; }
  504. .renewal-row-meta { font-size: 12px; color: var(--text3); display: flex; flex-wrap: wrap; gap: 12px; }
  505. .renewal-row-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
  506. .days-badge {
  507. font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 600;
  508. background: #fef2f2; color: var(--danger);
  509. }
  510. .days-badge.warn { background: #fff7ed; color: var(--warn); }
  511. .days-badge.ok { background: #ecfdf5; color: #059669; }
  512. /* pipeline lists (投标/储备) */
  513. .pipeline-row {
  514. display: grid; grid-template-columns: 1fr auto; gap: 12px; padding: 14px 16px;
  515. border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.12s;
  516. }
  517. .pipeline-row:hover { background: #f8fafb; }
  518. .pipeline-row:last-child { border-bottom: none; }
  519. .pipeline-row-title { font-weight: 600; font-size: 14px; margin-bottom: 6px; }
  520. .pipeline-row-meta { font-size: 12px; color: var(--text3); display: flex; flex-wrap: wrap; gap: 12px; }
  521. .pipeline-row-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
  522. .tag-bid { background: #eff6ff; color: #2563eb; }
  523. .tag-reserve { background: #f5f3ff; color: #7c3aed; }
  524. /* bid task deepen */
  525. .bid-summary-bar {
  526. display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 16px;
  527. }
  528. .bid-summary-item {
  529. background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
  530. padding: 12px 14px; font-size: 12px;
  531. }
  532. .bid-summary-item b { display: block; font-size: 18px; margin-top: 2px; color: var(--text); }
  533. .bid-summary-item span { color: var(--text3); }
  534. .bid-detail-panel {
  535. background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  536. margin-top: 12px; overflow: hidden;
  537. }
  538. .bid-detail-head {
  539. padding: 10px 14px; border-bottom: 1px solid var(--border);
  540. font-weight: 600; font-size: 13px; background: #fafbfc;
  541. display: flex; justify-content: space-between; align-items: center; gap: 8px;
  542. }
  543. .bid-detail-body { padding: 14px; }
  544. .mapping-table { width: 100%; font-size: 12px; margin-top: 8px; }
  545. .mapping-table th { font-size: 11px; padding: 6px 8px; }
  546. .mapping-table td { padding: 8px; vertical-align: top; }
  547. .mapping-ok { color: #059669; }
  548. .mapping-warn { color: var(--warn); }
  549. .req-list { list-style: none; margin: 8px 0; }
  550. .req-list li {
  551. padding: 6px 0 6px 12px; border-left: 2px solid var(--border); margin-bottom: 6px;
  552. font-size: 12px; color: var(--text2);
  553. }
  554. .req-list li.matched { border-left-color: var(--primary); }
  555. .history-compare {
  556. background: #fffbeb; border: 1px solid #fde68a; border-radius: 6px;
  557. padding: 10px 12px; font-size: 12px; color: #92400e; margin-top: 10px;
  558. }
  559. .score-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; }
  560. .score-item {
  561. text-align: center; padding: 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px;
  562. }
  563. .score-item b { display: block; font-size: 16px; color: var(--primary); margin-bottom: 2px; }
  564. @media (max-width: 1100px) {
  565. .renewal-layout { grid-template-columns: 1fr; }
  566. .stats { grid-template-columns: repeat(3, 1fr); }
  567. .pool-stats { grid-template-columns: repeat(2, 1fr); }
  568. .push-grid { grid-template-columns: repeat(2, 1fr); }
  569. .bid-workspace {
  570. grid-template-columns: 1fr;
  571. grid-template-rows: auto auto auto minmax(42vh, 1fr);
  572. min-height: auto;
  573. }
  574. .bid-editor-zone { grid-column: 1; min-height: 42vh; }
  575. .bid-draft-editor, .bid-draft-readonly { min-height: calc(42vh - 108px); }
  576. .bid-summary-bar { grid-template-columns: repeat(2, 1fr); }
  577. .gis-top { padding-left: 16px; }
  578. }
  579. </style>
  580. </head>
  581. <body>
  582. <div class="layout" id="app-shell">
  583. <aside class="sidebar">
  584. <div class="brand">
  585. <div class="brand-icon">申</div>
  586. <div><strong>申勤物业</strong><span>数字化平台</span></div>
  587. </div>
  588. <nav class="nav" id="main-nav">
  589. <a href="#dashboard" data-route="dashboard">工作台</a>
  590. <a href="#gis" data-route="gis">空间一张图</a>
  591. <a href="#projects" data-route="projects">项目台账</a>
  592. <div class="nav-label">市场拓展</div>
  593. <a href="#market" data-route="market">市场拓展中心</a>
  594. <a href="#renewal" data-route="renewal">项目续标</a>
  595. <a href="#tender-pool" data-route="tender-pool">招标监测</a>
  596. <a href="#bid-workbench" data-route="bid-workbench">标书编制</a>
  597. <a href="#bid-result" data-route="bid-result">开标与中标</a>
  598. </nav>
  599. </aside>
  600. <div class="main">
  601. <!-- ========== 工作台 ========== -->
  602. <section id="view-dashboard" class="view">
  603. <header class="topbar">
  604. <div><h1>工作台</h1><p>今日待办与关键指标 · 续标预警优先展示</p></div>
  605. <input type="search" placeholder="搜索项目、人员…" style="padding:6px 12px;border:1px solid var(--border);border-radius:6px;width:200px" />
  606. </header>
  607. <div class="content">
  608. <div class="stats" id="dashboard-stats"></div>
  609. <div class="grid2">
  610. <div class="card">
  611. <div class="card-head">今日待办 <span class="tag tag-danger" style="float:right">续标优先</span></div>
  612. <div class="card-body"><ul class="todo-list" id="todo-list"></ul></div>
  613. </div>
  614. <div class="card">
  615. <div class="card-head">快捷入口</div>
  616. <div class="card-body">
  617. <div class="quick-links">
  618. <a class="quick-card" href="#market">
  619. <strong>市场拓展中心</strong><span>招标 · 标书 · 投标 · 续标</span>
  620. </a>
  621. <a class="quick-card" href="#renewal">
  622. <strong>项目续标</strong><span>续标项目列表 · 全流程跟进</span>
  623. </a>
  624. <a class="quick-card quick-card--primary" href="#gis">
  625. <strong>空间一张图</strong><span>查看续标预警项目</span>
  626. </a>
  627. <a class="quick-card" href="#bid-workbench">
  628. <strong>标书编制</strong><span>拆解 · 确认 · 生成</span>
  629. </a>
  630. </div>
  631. </div>
  632. </div>
  633. </div>
  634. <div class="card">
  635. <div class="card-head">近期项目</div>
  636. <div class="card-body" style="padding:0">
  637. <table>
  638. <thead><tr><th>项目简称</th><th>在管服务</th><th>合同起止</th><th>线上</th><th>状态</th></tr></thead>
  639. <tbody id="recent-projects"></tbody>
  640. </table>
  641. </div>
  642. </div>
  643. </div>
  644. </section>
  645. <!-- ========== 项目台账 ========== -->
  646. <section id="view-projects" class="view hidden">
  647. <header class="topbar">
  648. <div><h1>项目台账</h1><p>在管项目一览</p></div>
  649. </header>
  650. <div class="content">
  651. <div class="card">
  652. <div class="card-body" style="padding:0">
  653. <table>
  654. <thead><tr><th>项目简称</th><th>金蝶编码</th><th>合同起止</th><th>距到期</th><th>操作</th></tr></thead>
  655. <tbody id="project-list"></tbody>
  656. </table>
  657. </div>
  658. </div>
  659. </div>
  660. </section>
  661. <!-- ========== 项目详情 ========== -->
  662. <section id="view-project" class="view hidden">
  663. <header class="topbar">
  664. <div>
  665. <p style="font-size:12px"><a href="#projects">← 项目台账</a></p>
  666. <h1 id="project-title">—</h1>
  667. <p id="project-sub">—</p>
  668. </div>
  669. <div style="display:flex;gap:8px;flex-wrap:wrap">
  670. <button type="button" class="btn" onclick="navigate('gis')">地图定位</button>
  671. <button type="button" class="btn btn-primary" id="btn-start-renewal">发起续标</button>
  672. </div>
  673. </header>
  674. <div class="content">
  675. <div id="project-banner"></div>
  676. <div class="stage-bar">
  677. <span class="dot done"></span><span class="dot done"></span><span class="dot done"></span>
  678. <span class="dot cur"></span><span class="dot"></span><span class="dot"></span>
  679. <span class="dot"></span><span class="dot"></span><span class="dot"></span><span class="dot"></span>
  680. <span style="margin-left:8px;font-size:13px;color:var(--text2)">当前:合同执行 · 即将进入续约/投标</span>
  681. </div>
  682. <div class="grid2">
  683. <div class="card">
  684. <div class="card-head">项目与甲方</div>
  685. <div class="card-body"><dl class="info info-wide" id="project-base"></dl></div>
  686. </div>
  687. <div class="card">
  688. <div class="card-head">合同与服务</div>
  689. <div class="card-body"><dl class="info info-wide" id="project-contract"></dl></div>
  690. </div>
  691. <div class="card">
  692. <div class="card-head">合同金额</div>
  693. <div class="card-body"><dl class="info info-wide" id="project-finance"></dl></div>
  694. </div>
  695. <div class="card">
  696. <div class="card-head">历史投标</div>
  697. <div class="card-body"><dl class="info" id="project-bids"></dl></div>
  698. </div>
  699. </div>
  700. <div class="card">
  701. <div class="card-head">续标入口</div>
  702. <div class="card-body">
  703. <p style="font-size:13px;color:var(--text2);margin-bottom:12px">续标请从项目续标列表进入,或直接进入本项目的续标详情页。</p>
  704. <button type="button" class="btn" onclick="navigate('renewal')">续标列表</button>
  705. <button type="button" class="btn btn-primary" style="margin-left:8px" onclick="startRenewal()">进入本项目续标 →</button>
  706. </div>
  707. </div>
  708. </div>
  709. </section>
  710. <!-- ========== 项目续标列表 ========== -->
  711. <section id="view-renewal" class="view hidden">
  712. <header class="topbar">
  713. <div>
  714. <p style="font-size:12px"><a href="#market">← 市场拓展中心</a></p>
  715. <h1>项目续标</h1>
  716. <p>合同到期预警 · 续标全流程跟进</p>
  717. </div>
  718. </header>
  719. <div class="content">
  720. <div class="pool-stats" id="renewal-stats"></div>
  721. <div class="card">
  722. <div class="card-head">续标项目列表</div>
  723. <div class="card-body" style="padding:12px 16px 0">
  724. <div class="pool-toolbar">
  725. <input type="search" id="renewal-search" placeholder="搜索项目简称、甲方…" style="flex:1;min-width:180px" oninput="renderRenewalList()" />
  726. <select id="renewal-region" onchange="renderRenewalList()">
  727. <option value="">全部区域</option>
  728. <option value="西">西</option>
  729. <option value="东">东</option>
  730. </select>
  731. </div>
  732. <div class="pool-chips" id="renewal-chips"></div>
  733. </div>
  734. <div id="renewal-list" style="padding:0"></div>
  735. </div>
  736. </div>
  737. </section>
  738. <!-- ========== 项目续标详情 ========== -->
  739. <section id="view-renewal-detail" class="view hidden">
  740. <header class="topbar">
  741. <div>
  742. <p style="font-size:12px"><a href="#renewal">← 项目续标</a> · <a href="#project">项目详情</a> · <a href="#market">市场拓展中心</a></p>
  743. <h1 id="renewal-page-title">项目续标</h1>
  744. <p id="renewal-page-sub">续标全流程 · 从预警到中标推送</p>
  745. </div>
  746. <div style="display:flex;gap:8px;flex-wrap:wrap" id="renewal-header-actions"></div>
  747. </header>
  748. <div class="content" id="renewal-content"></div>
  749. </section>
  750. <!-- ========== 市场拓展 ========== -->
  751. <section id="view-market" class="view hidden">
  752. <header class="topbar">
  753. <div><h1>市场拓展中心</h1><p>招标监测 · 标书编制 · 投标履约 · 项目续标</p></div>
  754. <button type="button" class="btn btn-primary" onclick="navigate('renewal')">进入项目续标 →</button>
  755. </header>
  756. <div class="content">
  757. <div class="grid2">
  758. <div class="card" style="border-color:var(--primary)">
  759. <div class="card-head">项目续标 <span class="tag tag-warn" style="float:right">进行中</span></div>
  760. <div class="card-body">
  761. <p>合同预警 · 招标跟踪 · 标书编制 · 开标中标</p>
  762. <p style="margin-top:12px"><button type="button" class="btn btn-primary" onclick="navigate('renewal')">进入项目续标 →</button></p>
  763. </div>
  764. </div>
  765. <div class="card" style="border-color:var(--primary);cursor:pointer" onclick="navigate('tender-pool')">
  766. <div class="card-head">招标监测 <span class="tag tag-danger" id="market-pool-badge" style="float:right">今日 +3</span></div>
  767. <div class="card-body">
  768. <p>政府采购网抓取 · 关键词订阅 · 转投标 / 转储备</p>
  769. <p style="margin-top:8px;font-size:12px;color:var(--text3)" id="tender-monitor-status">—</p>
  770. <p style="margin-top:10px;display:flex;gap:6px;flex-wrap:wrap">
  771. <span class="btn btn-primary">查看招标信息 →</span>
  772. </p>
  773. <p style="margin-top:8px;font-size:12px"><a href="#tender-bids" onclick="event.stopPropagation()">投标项目</a> · <a href="#tender-reserve" onclick="event.stopPropagation()">储备项目</a></p>
  774. </div>
  775. </div>
  776. <div class="card" style="border-color:var(--primary)">
  777. <div class="card-head">标书编制 <span class="tag tag-warn" style="float:right" id="market-bid-badge">进行中 1</span></div>
  778. <div class="card-body">
  779. <p>需求拆解 · 数据填充 · 分步确认 · 生成导出</p>
  780. <p style="margin-top:12px"><button type="button" class="btn btn-primary" onclick="navigate('bid-workbench')">进入标书编制 →</button></p>
  781. </div>
  782. </div>
  783. <div class="card" style="cursor:pointer" onclick="navigate('bid-result')">
  784. <div class="card-head">开标与中标 <span class="tag tag-warn" id="market-result-badge" style="float:right">监控 0</span></div>
  785. <div class="card-body">
  786. <p>投标登记 · 开标监控 · 结果抓取 · 中标推送</p>
  787. <p style="margin-top:10px"><span class="btn btn-primary">进入 →</span></p>
  788. </div>
  789. </div>
  790. </div>
  791. </div>
  792. </section>
  793. <!-- ========== 招标监测 ========== -->
  794. <section id="view-tender-pool" class="view hidden">
  795. <header class="topbar">
  796. <div>
  797. <p style="font-size:12px"><a href="#market">← 市场拓展中心</a></p>
  798. <h1>招标监测</h1>
  799. <p>政府采购网抓取 · 续标监控 · 智能匹配</p>
  800. </div>
  801. <div style="display:flex;gap:8px;flex-wrap:wrap">
  802. <button type="button" class="btn" onclick="navigate('tender-bids')">投标项目 <span id="pool-link-bids"></span></button>
  803. <button type="button" class="btn" onclick="navigate('tender-reserve')">储备项目 <span id="pool-link-reserve"></span></button>
  804. <button type="button" class="btn" onclick="simulateTenderMatch()">模拟:续标公告匹配</button>
  805. <button type="button" class="btn btn-primary" onclick="navigate('bid-workbench')">标书编制</button>
  806. </div>
  807. </header>
  808. <div class="content">
  809. <div class="pool-stats" id="pool-stats"></div>
  810. <div class="card">
  811. <div class="card-head">信息列表</div>
  812. <div class="card-body" style="padding:12px 16px 0">
  813. <div class="pool-toolbar">
  814. <input type="search" id="pool-search" placeholder="搜索项目名称、关键词…" style="flex:1;min-width:180px" oninput="renderTenderPool()" />
  815. <select id="pool-source" onchange="renderTenderPool()">
  816. <option value="">全部来源</option>
  817. <option value="上海政府采购网">上海政府采购网</option>
  818. <option value="中国政府采购网">中国政府采购网</option>
  819. </select>
  820. <select id="pool-district" onchange="renderTenderPool()">
  821. <option value="">全部区域</option>
  822. <option value="青浦区">青浦区</option>
  823. <option value="徐汇区">徐汇区</option>
  824. </select>
  825. </div>
  826. <div class="pool-chips" id="pool-chips"></div>
  827. </div>
  828. <div id="tender-list" style="padding:0"></div>
  829. </div>
  830. </div>
  831. </section>
  832. <!-- ========== 投标项目列表 ========== -->
  833. <section id="view-tender-bids" class="view hidden">
  834. <header class="topbar">
  835. <div>
  836. <p style="font-size:12px"><a href="#tender-pool">← 招标监测</a> · <a href="#market">市场拓展中心</a></p>
  837. <h1>投标项目</h1>
  838. <p>从招标监测转入 · 标书编制 · 投标跟进</p>
  839. </div>
  840. <button type="button" class="btn btn-primary" onclick="navigate('bid-workbench')">标书编制 →</button>
  841. </header>
  842. <div class="content">
  843. <div class="pool-stats" id="bid-pipeline-stats"></div>
  844. <div class="card">
  845. <div class="card-head">投标项目列表</div>
  846. <div class="card-body" style="padding:12px 16px 0">
  847. <div class="pool-toolbar">
  848. <input type="search" id="bid-pipeline-search" placeholder="搜索项目名称…" style="flex:1;min-width:180px" oninput="renderTenderBidList()" />
  849. </div>
  850. <div class="pool-chips" id="bid-pipeline-chips"></div>
  851. </div>
  852. <div id="bid-pipeline-list" style="padding:0"></div>
  853. </div>
  854. </div>
  855. </section>
  856. <!-- ========== 储备项目列表 ========== -->
  857. <section id="view-tender-reserve" class="view hidden">
  858. <header class="topbar">
  859. <div>
  860. <p style="font-size:12px"><a href="#tender-pool">← 招标监测</a> · <a href="#market">市场拓展中心</a></p>
  861. <h1>储备项目</h1>
  862. <p>从招标监测转入 · 业态评估 · 择机转投标</p>
  863. </div>
  864. </header>
  865. <div class="content">
  866. <div class="pool-stats" id="reserve-stats"></div>
  867. <div class="card">
  868. <div class="card-head">储备项目列表</div>
  869. <div class="card-body" style="padding:12px 16px 0">
  870. <div class="pool-toolbar">
  871. <input type="search" id="reserve-search" placeholder="搜索项目名称、区域…" style="flex:1;min-width:180px" oninput="renderTenderReserveList()" />
  872. <select id="reserve-district" onchange="renderTenderReserveList()">
  873. <option value="">全部区域</option>
  874. <option value="青浦区">青浦区</option>
  875. <option value="徐汇区">徐汇区</option>
  876. </select>
  877. </div>
  878. <div class="pool-chips" id="reserve-chips"></div>
  879. </div>
  880. <div id="reserve-list" style="padding:0"></div>
  881. </div>
  882. </div>
  883. </section>
  884. <!-- ========== 标书编制列表 ========== -->
  885. <section id="view-bid-workbench" class="view hidden">
  886. <header class="topbar">
  887. <div><h1>标书编制</h1><p>需求拆解 · 数据映射 · 分步确认 · 生成导出</p></div>
  888. <button type="button" class="btn btn-primary" onclick="navigate('bid-task')">继续编制 →</button>
  889. </header>
  890. <div class="content">
  891. <div class="steps">
  892. <span class="step done">1 上传招标文件</span>
  893. <span class="step active">2 智能拆解</span>
  894. <span class="step">3 分步确认</span>
  895. <span class="step">4 生成导出</span>
  896. </div>
  897. <div class="card">
  898. <div class="card-head">进行中的续标任务</div>
  899. <div class="card-body" style="padding:0" id="bid-task-list"></div>
  900. </div>
  901. <div class="card" id="bid-upload-card">
  902. <div class="card-head">快速开始</div>
  903. <div class="card-body">
  904. <p style="font-size:13px;color:var(--text2);margin-bottom:12px">从招标监测获取标书后,可在此继续编制或打开关联任务。</p>
  905. <button type="button" class="btn" onclick="navigate('tender-pool')">从招标监测选择 →</button>
  906. <button type="button" class="btn btn-primary" style="margin-left:8px" onclick="navigate('bid-task')">继续编制 →</button>
  907. </div>
  908. </div>
  909. </div>
  910. </section>
  911. <!-- ========== 标书编制详情 ========== -->
  912. <section id="view-bid-task" class="view hidden">
  913. <header class="topbar">
  914. <div>
  915. <p style="font-size:12px"><a href="#bid-workbench">← 标书编制</a></p>
  916. <h1 id="bid-task-title">—</h1>
  917. <p>上:原文对照 · 映射参考 · 预览 &nbsp;|&nbsp; 下:草稿编辑(≥1/3 页面)</p>
  918. </div>
  919. <div style="display:flex;gap:8px;align-items:center">
  920. <div class="progress-bar" style="width:120px;margin:0"><i id="bid-progress-bar" style="width:0%"></i></div>
  921. <button type="button" class="btn" id="btn-export-word" onclick="exportWord()" disabled title="全部确认后可导出">导出 Word</button>
  922. <button type="button" class="btn btn-primary" id="btn-register-bid" onclick="goRegisterBid()" disabled title="导出后可登记投标">登记投标</button>
  923. <button type="button" class="btn btn-primary" id="btn-confirm-all" onclick="confirmAllBlocks()">确认当前块</button>
  924. </div>
  925. </header>
  926. <div class="content bid-task-content">
  927. <div class="steps" id="bid-task-steps"></div>
  928. <div class="bid-summary-bar" id="bid-summary-bar"></div>
  929. <div class="bid-workspace">
  930. <div class="bid-col bid-col-doc">
  931. <div class="bid-col-head">招标文件 · 原文对照</div>
  932. <div class="bid-col-body" id="bid-doc-left"></div>
  933. </div>
  934. <div class="bid-col bid-col-ref">
  935. <div class="bid-col-head">拆解清单 & 数据映射</div>
  936. <div class="bid-col-body" id="bid-blocks-mid"></div>
  937. </div>
  938. <div class="bid-col bid-col-preview">
  939. <div class="bid-col-head">生成预览</div>
  940. <div class="bid-col-body preview-doc" id="bid-preview-right"></div>
  941. </div>
  942. <div class="bid-editor-zone" id="bid-editor-zone"></div>
  943. </div>
  944. </div>
  945. </section>
  946. <!-- ========== 开标与中标 ========== -->
  947. <section id="view-bid-result" class="view hidden">
  948. <header class="topbar">
  949. <div>
  950. <p style="font-size:12px"><a href="#market">← 市场拓展中心</a></p>
  951. <h1>开标与中标</h1>
  952. <p>投标登记 · 开标监控 · 结果抓取 · 中标推送</p>
  953. </div>
  954. <button type="button" class="btn" id="btn-simulate-result" onclick="simulateOpenBidResult()" style="display:none">模拟:开标结果发布</button>
  955. </header>
  956. <div class="content">
  957. <div class="page-tabs" id="result-tabs">
  958. <button type="button" class="page-tab active" data-tab="register" onclick="setResultTab('register')">投标登记</button>
  959. <button type="button" class="page-tab" data-tab="monitor" onclick="setResultTab('monitor')">开标监控 <span id="monitor-tab-badge"></span></button>
  960. <button type="button" class="page-tab" data-tab="win" onclick="setResultTab('win')">中标登记与推送</button>
  961. </div>
  962. <!-- 投标登记 -->
  963. <div id="result-panel-register">
  964. <div class="card">
  965. <div class="card-head">登记投标信息 · 开启开标监控</div>
  966. <div class="card-body">
  967. <p style="font-size:13px;color:var(--text2);margin-bottom:16px">标书提交后在此登记投标信息,系统将监控政府采购网开标结果页,结果发布后自动抓取并推送。</p>
  968. <div class="form-grid" id="register-form"></div>
  969. <div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap">
  970. <button type="button" class="btn btn-primary" onclick="submitBidRegistration()">提交登记 · 开启监控</button>
  971. <button type="button" class="btn" onclick="prefillRegisterForm()">填充示例数据</button>
  972. </div>
  973. </div>
  974. </div>
  975. </div>
  976. <!-- 开标监控 -->
  977. <div id="result-panel-monitor" class="hidden">
  978. <div class="pool-stats" style="grid-template-columns:repeat(3,1fr);margin-bottom:16px" id="monitor-stats"></div>
  979. <div class="card">
  980. <div class="card-head">监控中的投标</div>
  981. <div class="card-body" id="monitor-list"></div>
  982. </div>
  983. </div>
  984. <!-- 中标登记与推送 -->
  985. <div id="result-panel-win" class="hidden">
  986. <div id="win-content"></div>
  987. </div>
  988. </div>
  989. </section>
  990. </div>
  991. </div>
  992. <!-- 招标详情抽屉 -->
  993. <div class="drawer-mask hidden" id="tender-drawer-mask" onclick="if(event.target===this)closeTenderDrawer()">
  994. <div class="drawer" onclick="event.stopPropagation()">
  995. <div class="drawer-head">
  996. <div><h2 style="font-size:16px" id="drawer-title">—</h2><p style="font-size:12px;color:var(--text3);margin-top:4px" id="drawer-sub">—</p></div>
  997. <button type="button" class="btn btn-ghost" onclick="closeTenderDrawer()">×</button>
  998. </div>
  999. <div class="drawer-body" id="drawer-body"></div>
  1000. </div>
  1001. </div>
  1002. <div class="toast-wrap" id="toast-wrap"></div>
  1003. <!-- ========== GIS 全屏 ========== -->
  1004. <div id="view-gis" class="view-gis hidden">
  1005. <div class="gis-top">
  1006. <a class="gis-back" href="#dashboard">← 返回工作台</a>
  1007. <h1>数据可视化平台 · 空间一张图</h1>
  1008. <span style="font-size:11px;color:var(--text3)" id="gis-clock"></span>
  1009. </div>
  1010. <aside class="gis-left">
  1011. <div class="search"><input type="search" placeholder="搜索项目、地址…" id="gis-search" /></div>
  1012. <p class="count" id="gis-count">共 4 条</p>
  1013. <div class="gis-list" id="gis-list"></div>
  1014. </aside>
  1015. <div class="map-mock">
  1016. <div class="map-road r1"></div>
  1017. <div class="map-road r2"></div>
  1018. <div class="zone renew" id="gis-zone-renew" style="left:52%;top:42%;width:14%;height:11%"></div>
  1019. <div class="pin-project" style="left:28%;top:38%" data-id="p001" title="青浦疾控中心"></div>
  1020. <div class="pin-project renew" style="left:59%;top:47%" data-id="renewal-demo" title="青浦-公安局"></div>
  1021. <div class="pin-project" style="left:35%;top:55%" data-id="p003" title="中山青浦分院"></div>
  1022. <div class="pin-project" style="left:45%;top:30%" data-id="p004" title="徐泾枢纽"></div>
  1023. </div>
  1024. <div class="gis-tools">
  1025. <strong style="display:block;margin-bottom:6px">图层</strong>
  1026. <label><input type="checkbox" checked /> 项目点</label>
  1027. <label><input type="checkbox" checked id="layer-renew" /> 合同即将到期</label>
  1028. <label><input type="checkbox" /> 人员点</label>
  1029. </div>
  1030. <div class="gis-info-card hidden" id="gis-card">
  1031. <header><h3 id="gis-card-title">—</h3><button type="button" class="btn btn-ghost" onclick="closeGisCard()">×</button></header>
  1032. <dl id="gis-card-body"></dl>
  1033. <div class="actions">
  1034. <button type="button" class="btn" onclick="navigate('project')">项目详情</button>
  1035. <button type="button" class="btn btn-primary" id="gis-btn-renewal">进入项目续标</button>
  1036. </div>
  1037. </div>
  1038. </div>
  1039. <script>
  1040. const DEMO_TODAY = new Date('2026-06-08');
  1041. const renewalDemo = {
  1042. id: 'renewal-demo',
  1043. region: '西',
  1044. kingdeeCode: 'XMSQ0161',
  1045. serialNo: 40,
  1046. clientName: '上海市公安局青浦分局',
  1047. name: '上海市公安局青浦分局物业保洁服务采购项目',
  1048. displayName: '青浦-公安局',
  1049. address: '青浦区范围',
  1050. area: 170581,
  1051. contractPeriod: '2025.06.20-2026.06.19',
  1052. contractEnd: '2026-06-19',
  1053. servicePeriod: '2025.06.20-2028.06.19,第1年',
  1054. contractAmount: 16686132,
  1055. annualContractAmount: 16686132,
  1056. currentYearAmount: 7771623.12,
  1057. estimatedAmount: 8914508.88,
  1058. existingAmount: 16686132,
  1059. newAmount: null,
  1060. serviceScope: '保洁/绿化/维修/会务',
  1061. contractStatusOnline: '已签',
  1062. contractStatusOffline: '已签',
  1063. staffCount: 68,
  1064. leader: '李明华',
  1065. tenderPlatform: '上海政府采购网',
  1066. historyBids: [
  1067. { year: 2023, result: '中标', amount: 6200000 },
  1068. { year: 2020, result: '中标', amount: 5800000 },
  1069. ],
  1070. };
  1071. const otherProjects = [
  1072. { id: 'p001', kingdeeCode: 'XMSQ0102', displayName: '青浦-图书馆', name: '2026年青浦区图书馆物业管理项目', contractPeriod: '2026.02.28-2027.02.27', contractEnd: '2027-02-27', serviceScope: '全委', contractStatusOnline: '已签' },
  1073. { id: 'p003', kingdeeCode: 'XMSQ0104', displayName: '青浦-司法中心', name: '司法中心物业管理服务项目', contractPeriod: '2026.01.29-2027.01.28', contractEnd: '2027-01-28', serviceScope: '全委', contractStatusOnline: '已签' },
  1074. { id: 'p004', kingdeeCode: 'XMSQ0108', displayName: '青浦-税务局', name: '国家税务总局上海市青浦区税务局物业管理服务', contractPeriod: '2026.01.01-2026.12.31', contractEnd: '2026-12-31', serviceScope: '全委', contractStatusOnline: '已签' },
  1075. ];
  1076. let tenderPool = [
  1077. {
  1078. id: 't-renewal', title: '上海市公安局青浦分局物业保洁服务采购项目公开招标公告',
  1079. source: '上海政府采购网', district: '青浦区', publishDate: '2026-06-05', deadline: '2026-06-25',
  1080. budget: 16686132, type: 'renewal', matchScore: 98, linkedProjectId: 'renewal-demo',
  1081. tenderNo: 'SH-GP-2026-0892',
  1082. sourceUrl: 'https://www.zfcg.sh.gov.cn/site/detail?articleId=SH-GP-2026-0892',
  1083. status: 'monitoring', keywords: ['保洁', '绿化', '青浦', '公安局'], convertedTo: null,
  1084. content: '采购人:上海市公安局青浦分局。年化合同额约 1,668.61 万元。服务期限三年。获取招标文件时间:2026年6月5日至6月20日。',
  1085. },
  1086. {
  1087. id: 't-002', title: '青浦区某学校物业管理服务采购项目招标公告',
  1088. source: '上海政府采购网', district: '青浦区', publishDate: '2026-06-07', deadline: '2026-06-28',
  1089. budget: 3200000, type: 'new', matchScore: 72, linkedProjectId: null,
  1090. tenderNo: 'SH-GP-2026-0915',
  1091. sourceUrl: 'https://www.zfcg.sh.gov.cn/site/detail?articleId=SH-GP-2026-0915',
  1092. status: 'new', keywords: ['学校', '公建物业'], convertedTo: null,
  1093. content: '采购人:青浦区教育局下属学校。预算320万元/年。',
  1094. },
  1095. {
  1096. id: 't-003', title: '徐汇区政务中心保洁保安外包服务招标公告',
  1097. source: '上海政府采购网', district: '徐汇区', publishDate: '2026-06-06', deadline: '2026-06-22',
  1098. budget: 4500000, type: 'new', matchScore: 65, linkedProjectId: null,
  1099. tenderNo: 'SH-GP-2026-0908',
  1100. sourceUrl: 'https://www.zfcg.sh.gov.cn/site/detail?articleId=SH-GP-2026-0908',
  1101. status: 'read', keywords: ['政务中心', '保洁保安'], convertedTo: 'reserve',
  1102. content: '采购人:徐汇区机关事务管理局。',
  1103. },
  1104. {
  1105. id: 't-004', title: '青浦区某医院后勤保障服务公开招标公告',
  1106. source: '中国政府采购网', district: '青浦区', publishDate: '2026-06-04', deadline: '2026-06-18',
  1107. budget: 8900000, type: 'new', matchScore: 81, linkedProjectId: null,
  1108. tenderNo: 'CCGP-2026-QP-0621',
  1109. sourceUrl: 'https://www.ccgp.gov.cn/ccgp/shanghai/site/detail?articleId=CCGP-2026-QP-0621',
  1110. status: 'read', keywords: ['医院', '后勤保障'], convertedTo: 'reserve',
  1111. content: '采购人:青浦区某公立医院。含运送、保洁、工程维保。',
  1112. },
  1113. {
  1114. id: 't-005', title: '青浦区练塘镇社区物业综合管理服务采购公告',
  1115. source: '上海政府采购网', district: '青浦区', publishDate: '2026-06-03', deadline: '2026-06-15',
  1116. budget: 1800000, type: 'new', matchScore: 58, linkedProjectId: null,
  1117. tenderNo: 'SH-GP-2026-0886',
  1118. sourceUrl: 'https://www.zfcg.sh.gov.cn/site/detail?articleId=SH-GP-2026-0886',
  1119. status: 'read', keywords: ['社区物业', '练塘'], convertedTo: null,
  1120. content: '采购人:练塘镇人民政府。',
  1121. },
  1122. ];
  1123. let bidPipeline = [];
  1124. let reserveProjects = [
  1125. {
  1126. id: 'rs-1', tenderId: 't-004',
  1127. title: '青浦区某医院后勤保障服务公开招标公告',
  1128. district: '青浦区', source: '中国政府采购网', budget: 8900000, matchScore: 81,
  1129. convertedAt: '2026-06-06', deadline: '2026-06-18', status: 'evaluating',
  1130. reason: '医院后勤业态匹配,预算890万/年,建议重点评估',
  1131. owner: '市场部 · 张敏',
  1132. },
  1133. {
  1134. id: 'rs-2', tenderId: 't-003',
  1135. title: '徐汇区政务中心保洁保安外包服务招标公告',
  1136. district: '徐汇区', source: '上海政府采购网', budget: 4500000, matchScore: 65,
  1137. convertedAt: '2026-06-05', deadline: '2026-06-22', status: 'watching',
  1138. reason: '跨区域项目,匹配度一般,持续观察',
  1139. owner: '市场部 · 李强',
  1140. },
  1141. ];
  1142. const bidBlocks = [
  1143. {
  1144. id: 'b1', title: '评分标准', type: 'auto', source: '历史中标对照 · 2023版', docRef: '第四章 评标办法', docLine: 7,
  1145. requirements: ['价格分 30 分', '技术分 50 分', '商务分 10 分', '信誉分 10 分'],
  1146. mappings: [
  1147. { field: '评分结构', source: '2023中标标书', value: '与历史一致', status: 'ok' },
  1148. { field: '价格分计算', source: '财务模板', value: '最低价法', status: 'ok' },
  1149. ],
  1150. draft: '评标办法响应:完全响应招标文件第四章评分标准,价格分采用最低价法,技术方案按分项得分…',
  1151. historyNote: '2023版沿用评分结构,本次无重大调整',
  1152. scores: [{ label: '价格分', pts: 30 }, { label: '技术分', pts: 50 }, { label: '商务信誉', pts: 20 }],
  1153. },
  1154. {
  1155. id: 'b2', title: '投标人资质要求', type: 'auto', source: '综合办 · 证照库', docRef: '第二章 投标人须知', docLine: 4,
  1156. requirements: ['物业一级资质', 'ISO9001 质量管理体系', '近3年无重大违法记录'],
  1157. mappings: [
  1158. { field: '营业执照', source: '综合办证照库', value: '91440300MA5XXXX', status: 'ok' },
  1159. { field: '物业资质', source: '综合办证照库', value: '一级 · 有效期至2027-12', status: 'ok' },
  1160. { field: 'ISO9001', source: '综合办证照库', value: '已认证', status: 'ok' },
  1161. ],
  1162. draft: '资质证明:营业执照、物业一级资质证书、ISO9001 认证证书均已自动关联…',
  1163. historyNote: '证照库已更新,无需人工补充',
  1164. },
  1165. {
  1166. id: 'b3', title: '项目管理机构', type: 'confirm', source: '人事 · 花名册 + 证书', docRef: '第五章 人员配置', docLine: 9,
  1167. requirements: ['项目经理 1 名 · 持证上岗', '安保主管 1 名', '工程主管 1 名', '保洁主管 1 名'],
  1168. mappings: [
  1169. { field: '项目经理', source: '人事花名册', value: '李明华 · 物业师证', status: 'ok' },
  1170. { field: '安保主管', source: '人事花名册', value: '王强 · 保安员证', status: 'ok' },
  1171. { field: '工程主管', source: '人事花名册', value: '张伟 · 电工证', status: 'warn' },
  1172. { field: '保洁主管', source: '人事花名册', value: '待确认人选', status: 'warn' },
  1173. ],
  1174. draft: '项目管理机构:项目经理李明华(10年机关物业经验),安保主管王强,工程主管张伟…保洁主管岗位待确认后补充。',
  1175. historyNote: '2023版项目经理为同一人,机构稳定性加分',
  1176. },
  1177. {
  1178. id: 'b4', title: '类似项目业绩', type: 'auto', source: '市场部 · 历史中标库', docRef: '第六章 业绩证明', docLine: 5,
  1179. requirements: ['近3年类似机关物业项目 ≥ 2 个', '单个合同额 ≥ 500 万/年'],
  1180. mappings: [
  1181. { field: '业绩1', source: '历史中标库', value: '2023 青浦公安分局 · 620万', status: 'ok' },
  1182. { field: '业绩2', source: '历史中标库', value: '2022 青浦图书馆 · 480万', status: 'ok' },
  1183. { field: '业绩3', source: '历史中标库', value: '2021 青浦司法中心 · 350万', status: 'ok' },
  1184. ],
  1185. draft: '类似业绩:青浦公安分局物业保洁(2023,620万/年)、青浦图书馆物业管理(2022,480万/年)…',
  1186. historyNote: '本项目即为2023中标续标,业绩高度匹配',
  1187. },
  1188. {
  1189. id: 'b5', title: '服务方案与技术响应', type: 'manual', source: 'AI 草稿 + 人工确认', docRef: '第七章 技术方案', docLine: 11,
  1190. requirements: ['安保服务标准与巡逻方案', '保洁频次与质量标准', '工程维保响应时限', '绿化养护方案', '会务保障流程'],
  1191. mappings: [
  1192. { field: '安保方案', source: 'AI 草稿', value: '已生成 · 待确认', status: 'warn' },
  1193. { field: '保洁方案', source: 'AI 草稿', value: '已生成 · 待确认', status: 'warn' },
  1194. { field: '工程维保', source: '历史标书+AI', value: '已生成 · 待确认', status: 'warn' },
  1195. ],
  1196. draft: '一、安保服务\n24小时门岗值守,每2小时巡逻一次,突发事件5分钟内响应…\n\n二、保洁服务\n办公区每日2次保洁,卫生间每2小时巡检…\n\n三、工程维保\n一般报修2小时内到场,紧急报修30分钟内响应…',
  1197. historyNote: 'AI 基于2023中标标书+本项目服务范围生成,需人工核对机关特殊要求',
  1198. },
  1199. {
  1200. id: 'b6', title: '商务报价结构', type: 'confirm', source: '财务区间 + 历史报价', docRef: '第八章 报价格式', docLine: 3,
  1201. requirements: ['报价格式符合招标文件要求', '分项报价:人工/材料/管理费', '不超过预算上限 1,668.61 万'],
  1202. mappings: [
  1203. { field: '总报价', source: '财务区间', value: '1,620 万/年(建议)', status: 'warn' },
  1204. { field: '人工成本', source: 'HR定编', value: '68人 · 920万', status: 'ok' },
  1205. { field: '管理费', source: '财务模板', value: '8%', status: 'ok' },
  1206. { field: '2023报价', source: '历史中标', value: '620万 → 本次建议1620万', status: 'warn' },
  1207. ],
  1208. draft: '商务报价:年化总报价 1,620 万元,其中人工成本 920 万、材料及耗材 380 万、管理费 8%…',
  1209. historyNote: '2023中标620万,本次预算上限1668万,建议报价1620万留竞争空间',
  1210. },
  1211. ];
  1212. const tenderDocLines = [
  1213. '第一章 招标公告',
  1214. ' 1.1 项目概况:机关办公区物业管理',
  1215. ' 1.2 预算金额:年化约 1,668 万元',
  1216. '第二章 投标人须知',
  1217. ' 2.1 资质要求:物业一级资质…',
  1218. ' 2.2 业绩要求:近3年类似项目…',
  1219. '第四章 评标办法',
  1220. ' 4.1 价格分 30 分 · 技术分 50 分…',
  1221. '第五章 人员配置要求',
  1222. ' 5.1 项目经理 1 名,持证上岗…',
  1223. '第七章 技术方案要求',
  1224. ' 7.1 安保、保洁、工程服务标准…',
  1225. ];
  1226. const renewalCatalog = [
  1227. { id: 'renewal-demo', region: '西', kingdeeCode: 'XMSQ0161', clientName: '上海市公安局青浦分局', displayName: '青浦-公安局', name: '上海市公安局青浦分局物业保洁服务采购项目', contractEnd: '2026-06-19', contractPeriod: '2025.06.20-2026.06.19', annualContractAmount: 16686132, serviceScope: '保洁/绿化/维修/会务', listStatus: 'active' },
  1228. { id: 'p004', region: '西', kingdeeCode: 'XMSQ0108', clientName: '国家税务总局上海市青浦区税务局', displayName: '青浦-税务局', name: '国家税务总局上海市青浦区税务局物业管理服务', contractEnd: '2026-12-31', contractPeriod: '2026.01.01-2026.12.31', annualContractAmount: 4200000, serviceScope: '全委', listStatus: 'prep' },
  1229. { id: 'p003', region: '西', kingdeeCode: 'XMSQ0104', clientName: '青浦区司法中心', displayName: '青浦-司法中心', name: '司法中心物业管理服务项目', contractEnd: '2027-01-28', contractPeriod: '2026.01.29-2027.01.28', annualContractAmount: 3500000, serviceScope: '全委', listStatus: 'watch' },
  1230. { id: 'p001', region: '西', kingdeeCode: 'XMSQ0102', clientName: '青浦区图书馆', displayName: '青浦-图书馆', name: '2026年青浦区图书馆物业管理项目', contractEnd: '2027-02-27', contractPeriod: '2026.02.28-2027.02.27', annualContractAmount: 4800000, serviceScope: '全委', listStatus: 'watch' },
  1231. { id: 'p-done', region: '东', kingdeeCode: 'XMSQ0088', clientName: '徐汇区机关事务管理局', displayName: '徐汇-政务中心', name: '徐汇区政务中心物业管理服务', contractEnd: '2025-12-31', contractPeriod: '2023.01.01-2025.12.31', annualContractAmount: 5100000, serviceScope: '全委', listStatus: 'completed', completedDate: '2025-11' },
  1232. ];
  1233. const appState = {
  1234. poolFilter: 'all',
  1235. bidPipelineFilter: 'all',
  1236. reserveFilter: 'all',
  1237. renewalFilter: 'all',
  1238. selectedRenewalId: 'renewal-demo',
  1239. selectedTenderId: null,
  1240. activeBlockId: 'b3',
  1241. confirmedBlocks: new Set(),
  1242. blockDrafts: {},
  1243. renewalTasks: [
  1244. { id: 'r1', text: '客户回访与续标意向确认', done: true, action: null },
  1245. { id: 'r2', text: '现场踏勘与服务评估', done: true, action: null },
  1246. { id: 'r3', text: '关注招标公告发布(监控中)', done: false, action: 'renewal' },
  1247. { id: 'r4', text: '报名并获取招标文件', done: false, action: 'renewal-fetch' },
  1248. { id: 'r5', text: '标书需求拆解与生成', done: false, action: 'bid-task' },
  1249. { id: 'r6', text: '投标登记与开标监控', done: false, action: 'bid-result' },
  1250. { id: 'r7', text: '中标确认与部门推送', done: false, action: 'bid-result-win' },
  1251. ],
  1252. bidTaskFromTender: false,
  1253. gisSelectedId: 'renewal-demo',
  1254. registerFormBuilt: false,
  1255. bidExported: false,
  1256. resultTab: 'register',
  1257. bidSubmissions: [],
  1258. selectedSubmissionId: null,
  1259. };
  1260. function daysUntil(dateStr) {
  1261. return Math.ceil((new Date(dateStr) - DEMO_TODAY) / 86400000);
  1262. }
  1263. function formatMoney(n) {
  1264. if (n == null || n === '' || Number.isNaN(Number(n))) return '—';
  1265. if (n >= 10000) return (n / 10000).toFixed(2) + ' 万';
  1266. return n + ' 元';
  1267. }
  1268. function formatYuan(n) {
  1269. if (n == null || n === '' || Number.isNaN(Number(n))) return '—';
  1270. return Number(n).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }) + ' 元';
  1271. }
  1272. function renderProjectFields(p) {
  1273. document.getElementById('project-base').innerHTML = `
  1274. <dt>区域</dt><dd>${p.region ?? '—'}</dd>
  1275. <dt>金蝶编码</dt><dd>${p.kingdeeCode ?? '—'}</dd>
  1276. <dt>序号</dt><dd>${p.serialNo ?? '—'}</dd>
  1277. <dt>甲方名称</dt><dd>${p.clientName ?? '—'}</dd>
  1278. <dt>项目名称</dt><dd>${p.name ?? '—'}</dd>
  1279. <dt>项目简称</dt><dd>${p.displayName ?? '—'}</dd>
  1280. <dt>项目地址</dt><dd>${p.address ?? '—'}</dd>
  1281. <dt>建筑面积</dt><dd>${p.area != null ? p.area.toLocaleString('zh-CN') + ' m²' : '—'}</dd>
  1282. `;
  1283. document.getElementById('project-contract').innerHTML = `
  1284. <dt>合同起止日期</dt><dd>${p.contractPeriod ?? '—'}</dd>
  1285. <dt>服务期限</dt><dd>${p.servicePeriod ?? '—'}</dd>
  1286. <dt>在管服务内容</dt><dd>${p.serviceScope ?? '—'}</dd>
  1287. <dt>合同状况(线上)</dt><dd>${p.contractStatusOnline ? `<span class="tag">${p.contractStatusOnline}</span>` : '—'}</dd>
  1288. <dt>合同状况(线下)</dt><dd>${p.contractStatusOffline ? `<span class="tag">${p.contractStatusOffline}</span>` : '—'}</dd>
  1289. `;
  1290. document.getElementById('project-finance').innerHTML = `
  1291. <dt>合同金额</dt><dd>${formatYuan(p.contractAmount)}</dd>
  1292. <dt>年化合同额</dt><dd>${formatYuan(p.annualContractAmount)}</dd>
  1293. <dt>2026年当期年化合同额</dt><dd>${formatYuan(p.currentYearAmount)}</dd>
  1294. <dt>预估合同额</dt><dd>${formatYuan(p.estimatedAmount)}</dd>
  1295. <dt>存量合同额</dt><dd>${formatYuan(p.existingAmount)}</dd>
  1296. <dt>新增合同额</dt><dd>${formatYuan(p.newAmount)}</dd>
  1297. `;
  1298. }
  1299. function fillTypeLabel(type) {
  1300. return { auto: ['🟢 自动', 'fill-auto'], confirm: ['🟡 需确认', 'fill-confirm'], manual: ['🔴 人工撰写', 'fill-manual'] }[type];
  1301. }
  1302. function toast(msg) {
  1303. const wrap = document.getElementById('toast-wrap');
  1304. const el = document.createElement('div');
  1305. el.className = 'toast';
  1306. el.textContent = msg;
  1307. wrap.appendChild(el);
  1308. setTimeout(() => el.remove(), 3200);
  1309. }
  1310. function getRenewalTender() {
  1311. return tenderPool.find(t => t.id === 't-renewal');
  1312. }
  1313. function simulateTenderMatch() {
  1314. const t = getRenewalTender();
  1315. if (t) {
  1316. t.status = 'matched';
  1317. t.publishDate = '2026-06-08';
  1318. appState.renewalTasks.find(x => x.id === 'r3').done = true;
  1319. toast('续标监控匹配成功:青浦-公安局 招标公告已发布');
  1320. renderTenderPool();
  1321. renderDashboard();
  1322. renderProject();
  1323. renderMarket();
  1324. if (location.hash === '#renewal-detail') renderRenewalHub();
  1325. }
  1326. }
  1327. function renderDashboard() {
  1328. const days = daysUntil(renewalDemo.contractEnd);
  1329. const t = getRenewalTender();
  1330. const matched = t && t.status === 'matched';
  1331. document.getElementById('dashboard-stats').innerHTML = `
  1332. <div class="stat clickable" onclick="navigate('projects')"><label>在管项目</label><b>108</b><small>本年新增 6</small></div>
  1333. <div class="stat clickable" onclick="navigate('projects')"><label>在岗人员</label><b>3286</b><small>编制满足率 94%</small></div>
  1334. <div class="stat"><label>年化收入</label><b>2.86亿</b><small>预算执行 87%</small></div>
  1335. <div class="stat"><label>平均毛利率</label><b>10.8%</b><small>低于目标 3 项</small></div>
  1336. <div class="stat warn clickable" onclick="navigate('project')"><label>待续约</label><b>1</b><small>3个月内到期</small></div>
  1337. <div class="stat warn clickable" onclick="navigate('tender-pool')"><label>招标信息</label><b>${matched ? '1' : '3'}</b><small>${matched ? '续标已匹配' : '今日新增'} →</small></div>
  1338. `;
  1339. document.getElementById('todo-list').innerHTML = `
  1340. <li class="todo-item">
  1341. <span class="todo-dot urgent"></span>
  1342. <div><a href="#renewal-detail"><strong>${renewalDemo.displayName}</strong> 续标进行中 · 合同剩 ${days} 天 → 进入续标详情</a></div>
  1343. </li>
  1344. <li class="todo-item">
  1345. <span class="todo-dot ${appState.bidSubmissions.some(s => s.status === 'result_fetched') ? 'urgent' : ''}"></span>
  1346. <div><a href="#renewal-detail">${appState.bidSubmissions.some(s => s.status === 'result_fetched') ? '【开标结果】已中标,请完成登记推送' : appState.bidSubmissions.some(s => s.status === 'monitoring') ? '开标监控中 · 距开标 ' + daysUntilOpen(appState.bidSubmissions[0]) + ' 天' : matched ? '招标公告已匹配 · 请获取标书' : '项目续标 · 招标公告监控中'}</a></div>
  1347. </li>
  1348. <li class="todo-item"><span class="todo-dot"></span><div>练塘镇物业结项报告待审核</div></li>
  1349. <li class="todo-item"><span class="todo-dot"></span><div>疾控中心项目缺编 2 名保洁</div></li>
  1350. `;
  1351. document.getElementById('recent-projects').innerHTML = [renewalDemo, ...otherProjects].map(p => {
  1352. const d = daysUntil(p.contractEnd);
  1353. const isDemo = p.id === renewalDemo.id;
  1354. return `<tr onclick="navigate('project')" style="cursor:pointer">
  1355. <td><strong>${p.displayName}</strong><div style="font-size:11px;color:var(--text3)">${p.kingdeeCode || ''}</div></td>
  1356. <td>${p.serviceScope || '—'}</td>
  1357. <td>${p.contractPeriod || p.contractEnd}${isDemo ? ` <span class="tag tag-danger">${d}天</span>` : ''}</td>
  1358. <td>${p.contractStatusOnline || '—'}</td>
  1359. <td>${isDemo ? '<span class="tag tag-warn">续标预警</span>' : '<span class="tag">在管</span>'}</td>
  1360. </tr>`;
  1361. }).join('');
  1362. }
  1363. function renderProjectList() {
  1364. document.getElementById('project-list').innerHTML = [renewalDemo, ...otherProjects].map(p => {
  1365. const d = daysUntil(p.contractEnd);
  1366. return `<tr>
  1367. <td><a href="#project">${p.displayName}</a></td>
  1368. <td>${p.kingdeeCode || '—'}</td>
  1369. <td>${p.contractPeriod || p.contractEnd}</td>
  1370. <td>${d > 180 ? d + ' 天' : `<span class="tag tag-warn">${d} 天</span>`}</td>
  1371. <td><a href="#project">查看</a></td>
  1372. </tr>`;
  1373. }).join('');
  1374. }
  1375. function getRenewalStage() {
  1376. const t = getRenewalTender();
  1377. const matched = t?.status === 'matched';
  1378. const hasBid = appState.bidTaskFromTender;
  1379. const bidDone = appState.confirmedBlocks.size === bidBlocks.length;
  1380. const exported = appState.bidExported;
  1381. const submitted = appState.bidSubmissions.some(s => s.projectId === renewalDemo.id);
  1382. const monitoring = appState.bidSubmissions.find(s => s.projectId === renewalDemo.id && s.status === 'monitoring');
  1383. const resultFetched = appState.bidSubmissions.find(s => s.projectId === renewalDemo.id && s.status === 'result_fetched');
  1384. const pushed = appState.bidSubmissions.some(s => s.projectId === renewalDemo.id && s.status === 'pushed');
  1385. if (pushed) return 7;
  1386. if (resultFetched) return 7;
  1387. if (monitoring || submitted) return 6;
  1388. if (exported) return 5;
  1389. if (bidDone || hasBid) return 4;
  1390. if (matched) return 4;
  1391. if (appState.renewalTasks.find(x => x.id === 'r2').done) return 3;
  1392. return 2;
  1393. }
  1394. function getRenewalProject(id) {
  1395. if (id === 'renewal-demo') return renewalDemo;
  1396. const cat = renewalCatalog.find(x => x.id === id);
  1397. if (!cat) return renewalDemo;
  1398. return { ...cat, ...(otherProjects.find(p => p.id === id) || {}) };
  1399. }
  1400. function getRenewalListStatus(p) {
  1401. if (p.listStatus === 'completed') return { label: '已完成', tag: 'tag', days: null };
  1402. if (p.id === 'renewal-demo' && p.listStatus === 'active') {
  1403. const stageLabels = ['', '预警触发', '续标准备', '招标监控', '标书制作', '投标提交', '开标监控', '中标推送'];
  1404. return { label: '进行中 · ' + stageLabels[getRenewalStage()], tag: 'tag-warn', days: daysUntil(p.contractEnd) };
  1405. }
  1406. const days = daysUntil(p.contractEnd);
  1407. if (days <= 90) return { label: '90天内到期', tag: 'tag-danger', days };
  1408. if (days <= 180) return { label: '续标准备', tag: 'tag-warn', days };
  1409. return { label: '监控中', tag: 'tag', days };
  1410. }
  1411. function setRenewalFilter(f) {
  1412. appState.renewalFilter = f;
  1413. renderRenewalList();
  1414. }
  1415. function renderRenewalList() {
  1416. const search = (document.getElementById('renewal-search')?.value || '').trim().toLowerCase();
  1417. const region = document.getElementById('renewal-region')?.value || '';
  1418. const filter = appState.renewalFilter;
  1419. let items = renewalCatalog.map(p => ({ ...p, ...(p.id === 'renewal-demo' ? renewalDemo : otherProjects.find(x => x.id === p.id) || {}) }));
  1420. if (search) items = items.filter(p => (p.displayName + p.clientName + p.name).toLowerCase().includes(search));
  1421. if (region) items = items.filter(p => p.region === region);
  1422. if (filter === 'active') items = items.filter(p => p.listStatus === 'active');
  1423. else if (filter === 'urgent') items = items.filter(p => p.listStatus !== 'completed' && daysUntil(p.contractEnd) <= 90);
  1424. else if (filter === 'prep') items = items.filter(p => p.listStatus === 'prep' || p.listStatus === 'watch');
  1425. else if (filter === 'completed') items = items.filter(p => p.listStatus === 'completed');
  1426. const active = renewalCatalog.filter(p => p.listStatus === 'active').length;
  1427. const urgent = renewalCatalog.filter(p => p.listStatus !== 'completed' && daysUntil(p.contractEnd) <= 90).length;
  1428. const prep = renewalCatalog.filter(p => p.listStatus === 'prep' || p.listStatus === 'watch').length;
  1429. const done = renewalCatalog.filter(p => p.listStatus === 'completed').length;
  1430. document.getElementById('renewal-stats').innerHTML = `
  1431. <div class="pool-stat ${filter === 'all' ? 'highlight' : ''}" onclick="setRenewalFilter('all')"><span>全部</span><b>${renewalCatalog.length}</b></div>
  1432. <div class="pool-stat ${filter === 'active' ? 'highlight' : ''}" onclick="setRenewalFilter('active')"><span>进行中</span><b>${active}</b></div>
  1433. <div class="pool-stat ${filter === 'urgent' ? 'highlight' : ''}" onclick="setRenewalFilter('urgent')"><span>90天内到期</span><b>${urgent}</b></div>
  1434. <div class="pool-stat ${filter === 'completed' ? 'highlight' : ''}" onclick="setRenewalFilter('completed')"><span>已完成</span><b>${done}</b></div>
  1435. `;
  1436. document.getElementById('renewal-chips').innerHTML = [
  1437. ['all', '全部'], ['active', '进行中'], ['urgent', '90天内'], ['prep', '续标准备'], ['completed', '已完成'],
  1438. ].map(([k, label]) => `<button type="button" class="chip ${filter === k ? 'active' : ''}" onclick="setRenewalFilter('${k}')">${label}</button>`).join('');
  1439. if (!items.length) {
  1440. document.getElementById('renewal-list').innerHTML = `<div class="empty-shell" style="border:none;padding:32px"><strong>暂无匹配项目</strong></div>`;
  1441. return;
  1442. }
  1443. document.getElementById('renewal-list').innerHTML = items.map(p => {
  1444. const st = getRenewalListStatus(p);
  1445. const daysCls = st.days != null && st.days <= 30 ? '' : st.days != null && st.days <= 90 ? 'warn' : st.days != null ? 'ok' : '';
  1446. return `<div class="renewal-row" onclick="openRenewalDetail('${p.id}')">
  1447. <div>
  1448. <div class="renewal-row-title">${p.displayName}</div>
  1449. <div class="renewal-row-meta">
  1450. <span>${p.clientName || p.displayName}</span>
  1451. <span>合同 ${p.contractPeriod || p.contractEnd}</span>
  1452. <span>年化 ${formatMoney(p.annualContractAmount)}</span>
  1453. <span>${p.serviceScope || '—'}</span>
  1454. </div>
  1455. </div>
  1456. <div class="renewal-row-actions">
  1457. ${st.days != null ? `<span class="days-badge ${daysCls}">剩 ${st.days} 天</span>` : ''}
  1458. <span class="tag ${st.tag}">${st.label}</span>
  1459. <button type="button" class="btn btn-primary" style="font-size:12px;padding:4px 10px" onclick="event.stopPropagation();openRenewalDetail('${p.id}')">进入 →</button>
  1460. </div>
  1461. </div>`;
  1462. }).join('');
  1463. }
  1464. function openRenewalDetail(id) {
  1465. appState.selectedRenewalId = id;
  1466. navigate('renewal-detail');
  1467. }
  1468. function renderRenewalHub() {
  1469. const pid = appState.selectedRenewalId || 'renewal-demo';
  1470. if (pid !== 'renewal-demo') {
  1471. renderRenewalDetailSimple(pid);
  1472. return;
  1473. }
  1474. const p = renewalDemo;
  1475. const days = daysUntil(p.contractEnd);
  1476. const t = getRenewalTender();
  1477. const matched = t?.status === 'matched';
  1478. const stage = getRenewalStage();
  1479. const sub = appState.bidSubmissions.find(s => s.projectId === p.id);
  1480. document.getElementById('renewal-page-title').textContent = p.displayName + ' · 项目续标';
  1481. document.getElementById('renewal-page-sub').textContent = p.address + ' · 合同到期 ' + p.contractEnd;
  1482. const steps = [
  1483. { n: 1, label: '预警触发' },
  1484. { n: 2, label: '续标准备' },
  1485. { n: 3, label: '招标监控' },
  1486. { n: 4, label: '标书制作' },
  1487. { n: 5, label: '投标提交' },
  1488. { n: 6, label: '开标监控' },
  1489. { n: 7, label: '中标推送' },
  1490. ];
  1491. const stepperHtml = steps.map((s, i) => {
  1492. const cls = s.n < stage ? 'done' : s.n === stage ? 'active' : '';
  1493. const arrow = i < steps.length - 1 ? '<span class="renewal-step-arrow">→</span>' : '';
  1494. return `<div class="renewal-step ${cls}"><span class="renewal-step-dot">${s.n < stage ? '✓' : s.n}</span>${s.label}</div>${arrow}`;
  1495. }).join('');
  1496. const actionMap = {
  1497. 2: { title: '完成续标准备', desc: '确认客户回访与现场踏勘已完成,准备进入招标监控阶段。', primary: '标记准备完成', primaryFn: 'completeRenewalPrep()', secondary: null },
  1498. 3: { title: '监控招标公告', desc: `系统正在监控 ${p.tenderPlatform},匹配本项目续标公告。`, primary: matched ? '查看匹配公告' : '模拟:公告发布', primaryFn: matched ? 'scrollRenewalTender()' : 'simulateTenderMatch()', secondary: '招标监测', secondaryFn: "navigate('tender-pool')" },
  1499. 4: { title: matched ? '制作续标投标文件' : '等待招标公告', desc: matched ? '招标文件已就绪,进入标书编制完成拆解与生成。' : '公告发布后将自动关联本项目,届时可获取招标文件。', primary: matched ? '进入标书编制' : '模拟公告发布', primaryFn: matched ? "navigate('bid-task')" : 'simulateTenderMatch()', secondary: matched ? '获取标书' : null, secondaryFn: matched ? "fetchTenderFromRenewal()" : null },
  1500. 5: { title: '提交投标并登记', desc: '标书完成后导出 Word,登记投标信息以便开标监控。', primary: appState.bidExported ? '登记投标' : '继续制作标书', primaryFn: appState.bidExported ? "navigate('bid-result')" : "navigate('bid-task')", secondary: null },
  1501. 6: { title: '开标监控中', desc: sub ? `已登记投标 · 开标 ${sub.openBidDate} ${sub.openBidTime} · 距开标 ${daysUntilOpen(sub)} 天` : '请先完成投标登记', primary: sub?.status === 'monitoring' ? '模拟:开标结果发布' : '登记投标', primaryFn: sub?.status === 'monitoring' ? "simulateOpenBidResult('" + sub.id + "')" : "navigate('bid-result')", secondary: '查看监控详情', secondaryFn: "navigate('bid-result');setResultTab('monitor')" },
  1502. 7: { title: sub?.status === 'pushed' ? '续标流程已完成' : '确认中标并推送', desc: sub?.status === 'pushed' ? '已推送运营、HR、财务、条线,等待前介启动。' : '开标结果已出,请确认中标信息并推送至各职能部门。', primary: sub?.status === 'pushed' ? '查看推送记录' : '中标登记与推送', primaryFn: sub?.status === 'pushed' ? "navigate('bid-result');setResultTab('win')" : "navigate('bid-result');setResultTab('win')", secondary: null },
  1503. };
  1504. const action = actionMap[stage] || actionMap[3];
  1505. document.getElementById('renewal-header-actions').innerHTML = `
  1506. <button type="button" class="btn" onclick="navigate('gis')">地图</button>
  1507. <button type="button" class="btn" onclick="navigate('project')">项目档案</button>
  1508. `;
  1509. const tenderMini = t ? `
  1510. <div class="tender-mini ${matched ? 'matched' : 'monitoring'}" id="renewal-tender-card">
  1511. <strong style="font-size:13px">${matched ? '● 已匹配公告' : '○ 监控中'}</strong>
  1512. <p style="font-size:12px;color:var(--text2);margin:8px 0">${t.title}</p>
  1513. <div style="font-size:11px;color:var(--text3)">发布 ${t.publishDate} · 截止 ${t.deadline} · 匹配 ${t.matchScore} 分</div>
  1514. <div style="margin-top:10px;display:flex;gap:6px;flex-wrap:wrap">
  1515. ${matched ? `<button type="button" class="btn btn-primary" style="font-size:12px;padding:4px 10px" onclick="fetchTenderFromRenewal()">获取标书</button>` : `<button type="button" class="btn" style="font-size:12px;padding:4px 10px" onclick="simulateTenderMatch()">模拟发布</button>`}
  1516. <button type="button" class="btn btn-ghost" style="font-size:12px;padding:4px 10px" onclick="openTenderDrawer('t-renewal')">详情</button>
  1517. </div>
  1518. </div>` : '<p style="color:var(--text3);font-size:12px">暂无关联招标信息</p>';
  1519. document.getElementById('renewal-content').innerHTML = `
  1520. <div class="renewal-hero">
  1521. <div>
  1522. <h2>${p.displayName}</h2>
  1523. <p>${p.name}</p>
  1524. <p style="margin-top:8px;font-size:12px;opacity:0.85">${p.clientName} · ${p.serviceScope} · 年化 ${formatMoney(p.annualContractAmount)}</p>
  1525. </div>
  1526. <div class="renewal-countdown">
  1527. <b>${days}</b>
  1528. <span>天后合同到期</span>
  1529. </div>
  1530. </div>
  1531. <div class="renewal-stepper">${stepperHtml}</div>
  1532. <div class="renewal-layout">
  1533. <div class="renewal-main">
  1534. <div class="action-card">
  1535. <h3>${action.title}</h3>
  1536. <p>${action.desc}</p>
  1537. <div class="actions">
  1538. <button type="button" class="btn btn-primary" onclick="${action.primaryFn}">${action.primary}</button>
  1539. ${action.secondary ? `<button type="button" class="btn" onclick="${action.secondaryFn}">${action.secondary}</button>` : ''}
  1540. </div>
  1541. </div>
  1542. <div class="card">
  1543. <div class="card-head">续标任务清单</div>
  1544. <div class="card-body"><ul class="checklist" id="renewal-hub-checklist"></ul></div>
  1545. </div>
  1546. <div class="card">
  1547. <div class="card-head">历史投标参考</div>
  1548. <div class="card-body">
  1549. <table>
  1550. <thead><tr><th>年份</th><th>结果</th><th>金额</th><th>操作</th></tr></thead>
  1551. <tbody>${p.historyBids.map(b => `<tr><td>${b.year}</td><td>${b.result}</td><td>${formatMoney(b.amount)}</td><td><a href="#" onclick="toast('历史标书档案接入后开放');return false">查看标书</a></td></tr>`).join('')}</tbody>
  1552. </table>
  1553. </div>
  1554. </div>
  1555. </div>
  1556. <div class="renewal-side">
  1557. <div class="side-card">
  1558. <div class="side-card-head">项目摘要</div>
  1559. <div class="side-card-body">
  1560. <dl class="info info-wide" style="grid-template-columns:96px 1fr">
  1561. <dt>金蝶编码</dt><dd>${p.kingdeeCode}</dd>
  1562. <dt>合同起止</dt><dd>${p.contractPeriod}</dd>
  1563. <dt>年化合同额</dt><dd>${formatYuan(p.annualContractAmount)}</dd>
  1564. <dt>在管服务内容</dt><dd>${p.serviceScope}</dd>
  1565. <dt>合同状况</dt><dd>${p.contractStatusOnline} / ${p.contractStatusOffline}</dd>
  1566. </dl>
  1567. </div>
  1568. </div>
  1569. <div class="side-card">
  1570. <div class="side-card-head">本项目招标动态</div>
  1571. <div class="side-card-body">${tenderMini}</div>
  1572. </div>
  1573. <div class="side-card">
  1574. <div class="side-card-head">关联模块</div>
  1575. <div class="side-card-body" style="display:flex;flex-direction:column;gap:8px">
  1576. <button type="button" class="btn" style="width:100%;text-align:left" onclick="navigate('bid-task')">标书编制</button>
  1577. <button type="button" class="btn" style="width:100%;text-align:left" onclick="navigate('bid-result')">开标与中标</button>
  1578. <button type="button" class="btn btn-ghost" style="width:100%;text-align:left;font-size:12px" onclick="navigate('tender-pool')">查看全部招标信息 →</button>
  1579. </div>
  1580. </div>
  1581. <div class="side-card">
  1582. <div class="side-card-head">流程记录</div>
  1583. <div class="side-card-body"><ul class="timeline">${buildRenewalTimeline().map(x => `<li class="${x.done ? 'done' : ''}">${x.text}</li>`).join('')}</ul></div>
  1584. </div>
  1585. </div>
  1586. </div>
  1587. `;
  1588. document.getElementById('renewal-hub-checklist').innerHTML = appState.renewalTasks.map(t =>
  1589. `<li class="${t.done ? 'done' : ''} clickable" onclick="onRenewalChecklistClick('${t.id}')">
  1590. <span>${t.done ? '✓' : '○'}</span> ${t.text}
  1591. </li>`
  1592. ).join('');
  1593. }
  1594. function renderRenewalDetailSimple(id) {
  1595. const p = getRenewalProject(id);
  1596. const days = p.contractEnd ? daysUntil(p.contractEnd) : '—';
  1597. const st = getRenewalListStatus(p);
  1598. document.getElementById('renewal-page-title').textContent = p.displayName + ' · 项目续标';
  1599. document.getElementById('renewal-page-sub').textContent = (p.clientName || p.displayName) + ' · 合同到期 ' + (p.contractEnd || '—');
  1600. document.getElementById('renewal-header-actions').innerHTML = `
  1601. <button type="button" class="btn" onclick="navigate('project')">项目档案</button>
  1602. `;
  1603. document.getElementById('renewal-content').innerHTML = `
  1604. <div class="banner-renewal ${days !== '—' && days <= 90 ? 'urgent' : ''}">
  1605. <div>
  1606. <strong>${st.label}</strong> · 合同 ${p.contractPeriod || p.contractEnd}
  1607. ${days !== '—' ? `<div style="font-size:12px;color:var(--text2);margin-top:6px">距本期到期 <strong>${days}</strong> 天 · 年化 ${formatMoney(p.annualContractAmount)}</div>` : ''}
  1608. </div>
  1609. ${days !== '—' && days <= 180 ? `<button type="button" class="btn btn-primary" onclick="toast('系统将自动监控招标公告,到期前90天启动续标流程')">加入续标监控</button>` : ''}
  1610. </div>
  1611. <div class="card">
  1612. <div class="card-head">项目信息</div>
  1613. <div class="card-body">
  1614. <dl class="info info-wide">
  1615. <dt>金蝶编码</dt><dd>${p.kingdeeCode || '—'}</dd>
  1616. <dt>甲方名称</dt><dd>${p.clientName || '—'}</dd>
  1617. <dt>项目名称</dt><dd>${p.name || '—'}</dd>
  1618. <dt>在管服务</dt><dd>${p.serviceScope || '—'}</dd>
  1619. </dl>
  1620. </div>
  1621. </div>
  1622. <div class="card">
  1623. <div class="card-head">续标状态</div>
  1624. <div class="card-body">
  1625. <p style="font-size:13px;color:var(--text2)">${p.listStatus === 'completed' ? '本项目续标已完成,可在历史记录中查看。' : days <= 90 ? '该项目即将进入续标关键期,建议提前启动客户回访与现场评估。' : '系统持续监控合同到期日与招标平台,到期前自动预警并创建续标任务。'}</p>
  1626. ${p.listStatus !== 'completed' ? `<button type="button" class="btn btn-primary" style="margin-top:12px" onclick="toast('续标流程将在合同到期前90天自动启动')">提前启动续标</button>` : ''}
  1627. </div>
  1628. </div>`;
  1629. }
  1630. function buildRenewalTimeline() {
  1631. const items = [
  1632. { text: '合同到期预警触发', done: true },
  1633. { text: '续标准备(回访、踏勘)', done: appState.renewalTasks.find(x => x.id === 'r2').done },
  1634. { text: '招标公告监控', done: getRenewalTender()?.status === 'matched' },
  1635. { text: '标书拆解与生成', done: appState.bidExported },
  1636. { text: '投标登记', done: appState.renewalTasks.find(x => x.id === 'r6').done },
  1637. { text: '开标结果抓取', done: appState.bidSubmissions.some(s => s.status === 'result_fetched' || s.status === 'pushed') },
  1638. { text: '中标推送完成', done: appState.renewalTasks.find(x => x.id === 'r7').done },
  1639. ];
  1640. return items;
  1641. }
  1642. function onRenewalChecklistClick(id) {
  1643. const t = appState.renewalTasks.find(x => x.id === id);
  1644. if (t?.done) return;
  1645. if (id === 'r3') simulateTenderMatch();
  1646. else if (id === 'r4') fetchTenderFromRenewal();
  1647. else if (id === 'r5') navigate('bid-task');
  1648. else if (id === 'r6') { navigate('bid-result'); setResultTab('register'); prefillRegisterForm(); }
  1649. else if (id === 'r7') { navigate('bid-result'); setResultTab('win'); }
  1650. }
  1651. function completeRenewalPrep() {
  1652. appState.renewalTasks.find(x => x.id === 'r1').done = true;
  1653. appState.renewalTasks.find(x => x.id === 'r2').done = true;
  1654. toast('续标准备已完成');
  1655. renderRenewalHub();
  1656. renderProject();
  1657. }
  1658. function fetchTenderFromRenewal() {
  1659. fetchTenderDoc('t-renewal', true);
  1660. }
  1661. function scrollRenewalTender() {
  1662. document.getElementById('renewal-tender-card')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
  1663. }
  1664. function renderProject() {
  1665. const p = renewalDemo;
  1666. const days = daysUntil(p.contractEnd);
  1667. document.getElementById('project-title').textContent = p.displayName;
  1668. document.getElementById('project-sub').textContent = `${p.name} · ${p.kingdeeCode}`;
  1669. document.getElementById('project-banner').innerHTML = `
  1670. <div class="banner-renewal urgent">
  1671. <div>
  1672. <strong>续标预警</strong> · 合同起止 ${p.contractPeriod},距本期到期 <strong>${days}</strong> 天
  1673. <div style="font-size:12px;color:var(--text2);margin-top:6px">服务期限 ${p.servicePeriod} · 请进入项目续标跟进全流程。</div>
  1674. </div>
  1675. <button type="button" class="btn btn-primary" onclick="startRenewal()">进入项目续标</button>
  1676. </div>
  1677. `;
  1678. renderProjectFields(p);
  1679. document.getElementById('project-bids').innerHTML = (p.historyBids || []).map(b =>
  1680. `<dt>${b.year}年</dt><dd>${b.result} · ${formatYuan(b.amount)}</dd>`
  1681. ).join('') || '<dt>—</dt><dd>暂无</dd>';
  1682. document.getElementById('btn-start-renewal').textContent = '进入项目续标';
  1683. document.getElementById('btn-start-renewal').onclick = startRenewal;
  1684. }
  1685. function onChecklistClick() { startRenewal(); }
  1686. function renderMarket() {
  1687. const t = getRenewalTender();
  1688. const matched = t && t.status === 'matched';
  1689. document.getElementById('tender-monitor-status').textContent = matched
  1690. ? `续标已匹配 · 投标 ${bidPipeline.length} · 储备 ${reserveProjects.length}`
  1691. : `待跟进 ${tenderPool.filter(t => !t.convertedTo).length} · 投标 ${bidPipeline.length} · 储备 ${reserveProjects.length}`;
  1692. document.getElementById('market-pool-badge').textContent = matched ? '续标已匹配' : '今日 +3';
  1693. document.getElementById('market-pool-badge').className = matched ? 'tag tag-danger' : 'tag';
  1694. const monitoring = appState.bidSubmissions.filter(s => s.status === 'monitoring').length;
  1695. const badge = document.getElementById('market-result-badge');
  1696. if (badge) badge.textContent = monitoring ? `监控 ${monitoring}` : (appState.bidSubmissions.some(s => s.status === 'result_fetched') ? '待登记' : '监控 0');
  1697. }
  1698. function renderTenderPool() {
  1699. syncBidPipelineItems();
  1700. const search = (document.getElementById('pool-search')?.value || '').toLowerCase();
  1701. const source = document.getElementById('pool-source')?.value || '';
  1702. const district = document.getElementById('pool-district')?.value || '';
  1703. const filter = appState.poolFilter;
  1704. let list = tenderPool.filter(t => {
  1705. if (filter !== 'converted' && t.convertedTo) return false;
  1706. if (filter === 'converted' && !t.convertedTo) return false;
  1707. if (filter === 'renewal' && t.type !== 'renewal') return false;
  1708. if (filter === 'high' && t.matchScore < 80) return false;
  1709. if (filter === 'today' && t.publishDate !== '2026-06-08' && t.publishDate !== '2026-06-07') return false;
  1710. if (filter === 'new' && t.status === 'read') return false;
  1711. if (source && t.source !== source) return false;
  1712. if (district && t.district !== district) return false;
  1713. if (search && !t.title.toLowerCase().includes(search) && !t.keywords.some(k => k.includes(search))) return false;
  1714. return true;
  1715. });
  1716. const poolActive = tenderPool.filter(t => !t.convertedTo).length;
  1717. const matched = tenderPool.filter(t => t.type === 'renewal' && t.status === 'matched').length;
  1718. const todayNew = tenderPool.filter(t => !t.convertedTo && t.publishDate >= '2026-06-07').length;
  1719. const high = tenderPool.filter(t => !t.convertedTo && t.matchScore >= 80).length;
  1720. document.getElementById('pool-stats').innerHTML = `
  1721. <div class="pool-stat ${filter === 'all' ? 'highlight' : ''}" onclick="setPoolFilter('all')"><span>待跟进</span><b>${poolActive}</b></div>
  1722. <div class="pool-stat" onclick="navigate('tender-bids')"><span>投标项目</span><b>${bidPipeline.length}</b></div>
  1723. <div class="pool-stat" onclick="navigate('tender-reserve')"><span>储备项目</span><b>${reserveProjects.length}</b></div>
  1724. <div class="pool-stat ${matched ? 'highlight' : ''}" onclick="setPoolFilter('renewal')"><span>续标监控</span><b>${tenderPool.filter(t => t.type === 'renewal').length}</b></div>
  1725. `;
  1726. const linkBids = document.getElementById('pool-link-bids');
  1727. const linkReserve = document.getElementById('pool-link-reserve');
  1728. if (linkBids) linkBids.textContent = bidPipeline.length ? `(${bidPipeline.length})` : '';
  1729. if (linkReserve) linkReserve.textContent = reserveProjects.length ? `(${reserveProjects.length})` : '';
  1730. const chips = [
  1731. ['all', '待跟进'], ['renewal', '续标监控'], ['high', '高优'], ['today', '近2日'], ['new', '未读'],
  1732. ];
  1733. document.getElementById('pool-chips').innerHTML = chips.map(([k, l]) =>
  1734. `<button type="button" class="chip ${filter === k ? 'active' : ''}" onclick="setPoolFilter('${k}')">${l}</button>`
  1735. ).join('');
  1736. document.getElementById('tender-list').innerHTML = list.length ? list.map(t => {
  1737. const typeTag = t.type === 'renewal'
  1738. ? `<span class="tag tag-renewal">${t.status === 'matched' ? '续标已匹配' : '续标监控'}</span>`
  1739. : `<span class="tag tag-new">新标</span>`;
  1740. const scoreCls = t.matchScore >= 80 ? 'high' : '';
  1741. const daysLeft = Math.ceil((new Date(t.deadline) - DEMO_TODAY) / 86400000);
  1742. return `<div class="tender-row ${appState.selectedTenderId === t.id ? 'selected' : ''}" onclick="openTenderDrawer('${t.id}')">
  1743. <div>
  1744. <div class="tender-title">${t.title}</div>
  1745. <div class="tender-meta">
  1746. <span>${t.source}</span><span>${t.district}</span>
  1747. <span>发布 ${t.publishDate}</span><span>截止 ${t.deadline}${daysLeft > 0 ? ' · 剩' + daysLeft + '天' : ''}</span>
  1748. <span>预算 ${formatMoney(t.budget)}</span>
  1749. ${typeTag}
  1750. <span class="score-badge ${scoreCls}">匹配 ${t.matchScore}</span>
  1751. </div>
  1752. </div>
  1753. <div class="tender-actions" onclick="event.stopPropagation()">
  1754. <button type="button" class="btn" style="font-size:12px;padding:4px 10px" onclick="openTenderDrawer('${t.id}')">详情</button>
  1755. <button type="button" class="btn btn-primary" style="font-size:12px;padding:4px 10px" onclick="convertToBid('${t.id}')">转投标</button>
  1756. <button type="button" class="btn" style="font-size:12px;padding:4px 10px" onclick="convertToReserve('${t.id}')">转储备</button>
  1757. </div>
  1758. </div>`;
  1759. }).join('') : '<div style="padding:32px;text-align:center;color:var(--text3)">暂无待跟进信息 · 已转入的项目请查看投标/储备列表</div>';
  1760. }
  1761. function buildBidPipelineItem(t) {
  1762. const linked = t.linkedProjectId === 'renewal-demo' ? renewalDemo.displayName : null;
  1763. return {
  1764. id: 'bp-' + t.id,
  1765. tenderId: t.id,
  1766. title: t.title,
  1767. district: t.district,
  1768. source: t.source,
  1769. budget: t.budget,
  1770. matchScore: t.matchScore,
  1771. deadline: t.deadline,
  1772. convertedAt: '2026-06-08',
  1773. linkedProject: linked,
  1774. type: t.type,
  1775. status: 'preparing',
  1776. bidProgress: 0,
  1777. };
  1778. }
  1779. function computeBidPipelineStatus(tenderId) {
  1780. if (tenderId === 't-renewal') {
  1781. if (appState.bidSubmissions.some(s => s.projectId === renewalDemo.id && s.status === 'monitoring')) return 'monitoring';
  1782. if (appState.bidSubmissions.some(s => s.projectId === renewalDemo.id && (s.status === 'result_fetched' || s.status === 'pushed'))) return 'submitted';
  1783. if (appState.bidExported) return 'ready';
  1784. if (appState.bidTaskFromTender) return 'preparing';
  1785. }
  1786. const item = bidPipeline.find(x => x.tenderId === tenderId);
  1787. return item?.status || 'preparing';
  1788. }
  1789. function syncBidPipelineItems() {
  1790. bidPipeline.forEach(item => {
  1791. if (item.tenderId === 't-renewal') {
  1792. item.bidProgress = Math.round((appState.confirmedBlocks.size / bidBlocks.length) * 100);
  1793. item.linkedProject = renewalDemo.displayName;
  1794. }
  1795. item.status = computeBidPipelineStatus(item.tenderId);
  1796. });
  1797. }
  1798. function ensureBidPipelineItem(tenderId) {
  1799. const t = tenderPool.find(x => x.id === tenderId);
  1800. if (!t) return null;
  1801. let item = bidPipeline.find(x => x.tenderId === tenderId);
  1802. if (!item) {
  1803. item = buildBidPipelineItem(t);
  1804. bidPipeline.unshift(item);
  1805. }
  1806. t.convertedTo = 'bid';
  1807. syncBidPipelineItems();
  1808. return item;
  1809. }
  1810. function convertToBid(tenderId, goList) {
  1811. const t = tenderPool.find(x => x.id === tenderId);
  1812. if (!t) return;
  1813. if (t.convertedTo === 'bid') {
  1814. toast('已在投标项目列表中');
  1815. if (goList) navigate('tender-bids');
  1816. return;
  1817. }
  1818. reserveProjects = reserveProjects.filter(r => r.tenderId !== tenderId);
  1819. ensureBidPipelineItem(tenderId);
  1820. closeTenderDrawer();
  1821. toast('已转入投标项目 · ' + t.title.slice(0, 20) + '…');
  1822. renderTenderPool();
  1823. renderTenderBidList();
  1824. renderTenderReserveList();
  1825. renderMarket();
  1826. if (goList) navigate('tender-bids');
  1827. }
  1828. function convertToReserve(tenderId, goList) {
  1829. const t = tenderPool.find(x => x.id === tenderId);
  1830. if (!t) return;
  1831. if (t.convertedTo === 'bid') { toast('该项目已转入投标,请先在投标列表中处理'); return; }
  1832. if (t.convertedTo === 'reserve' || reserveProjects.some(r => r.tenderId === tenderId)) {
  1833. toast('已在储备项目列表中');
  1834. if (goList) navigate('tender-reserve');
  1835. return;
  1836. }
  1837. t.convertedTo = 'reserve';
  1838. reserveProjects.unshift({
  1839. id: 'rs-' + Date.now(),
  1840. tenderId: t.id,
  1841. title: t.title,
  1842. district: t.district,
  1843. source: t.source,
  1844. budget: t.budget,
  1845. matchScore: t.matchScore,
  1846. convertedAt: '2026-06-08',
  1847. deadline: t.deadline,
  1848. status: t.matchScore >= 80 ? 'evaluating' : 'watching',
  1849. reason: t.matchScore >= 80 ? '业态匹配,待市场部评估人力与报价区间' : '匹配度一般,纳入观察清单',
  1850. owner: '市场部',
  1851. });
  1852. closeTenderDrawer();
  1853. toast('已转入储备项目');
  1854. renderTenderPool();
  1855. renderTenderReserveList();
  1856. renderMarket();
  1857. if (goList) navigate('tender-reserve');
  1858. }
  1859. function reserveToBid(reserveId) {
  1860. const r = reserveProjects.find(x => x.id === reserveId);
  1861. if (!r) return;
  1862. reserveProjects = reserveProjects.filter(x => x.id !== reserveId);
  1863. convertToBid(r.tenderId, false);
  1864. toast('储备项目已转投标');
  1865. renderTenderReserveList();
  1866. renderTenderBidList();
  1867. }
  1868. function removeFromReserve(reserveId) {
  1869. const r = reserveProjects.find(x => x.id === reserveId);
  1870. if (!r) return;
  1871. const t = tenderPool.find(x => x.id === r.tenderId);
  1872. if (t) t.convertedTo = null;
  1873. reserveProjects = reserveProjects.filter(x => x.id !== reserveId);
  1874. toast('已移出储备,招标信息恢复待跟进');
  1875. renderTenderReserveList();
  1876. renderTenderPool();
  1877. renderMarket();
  1878. }
  1879. function bidStatusLabel(status) {
  1880. return {
  1881. preparing: ['标书编制中', 'tag-warn'],
  1882. ready: ['待投标', 'tag-warn'],
  1883. monitoring: ['开标监控', 'tag-danger'],
  1884. submitted: ['已投标', 'tag'],
  1885. }[status] || ['跟进中', 'tag'];
  1886. }
  1887. function reserveStatusLabel(status) {
  1888. return {
  1889. watching: ['观察中', 'tag'],
  1890. evaluating: ['评估中', 'tag-warn'],
  1891. ready: ['待转投标', 'tag-bid'],
  1892. }[status] || ['观察中', 'tag'];
  1893. }
  1894. function setBidPipelineFilter(f) {
  1895. appState.bidPipelineFilter = f;
  1896. renderTenderBidList();
  1897. }
  1898. function setReserveFilter(f) {
  1899. appState.reserveFilter = f;
  1900. renderTenderReserveList();
  1901. }
  1902. function renderTenderBidList() {
  1903. syncBidPipelineItems();
  1904. const search = (document.getElementById('bid-pipeline-search')?.value || '').toLowerCase();
  1905. const filter = appState.bidPipelineFilter;
  1906. let list = [...bidPipeline];
  1907. if (search) list = list.filter(x => x.title.toLowerCase().includes(search));
  1908. if (filter === 'renewal') list = list.filter(x => x.type === 'renewal');
  1909. else if (filter === 'preparing') list = list.filter(x => x.status === 'preparing' || x.status === 'ready');
  1910. else if (filter === 'monitoring') list = list.filter(x => x.status === 'monitoring' || x.status === 'submitted');
  1911. const preparing = bidPipeline.filter(x => x.status === 'preparing' || x.status === 'ready').length;
  1912. const monitoring = bidPipeline.filter(x => x.status === 'monitoring' || x.status === 'submitted').length;
  1913. const renewal = bidPipeline.filter(x => x.type === 'renewal').length;
  1914. document.getElementById('bid-pipeline-stats').innerHTML = `
  1915. <div class="pool-stat ${filter === 'all' ? 'highlight' : ''}" onclick="setBidPipelineFilter('all')"><span>全部</span><b>${bidPipeline.length}</b></div>
  1916. <div class="pool-stat ${filter === 'preparing' ? 'highlight' : ''}" onclick="setBidPipelineFilter('preparing')"><span>标书阶段</span><b>${preparing}</b></div>
  1917. <div class="pool-stat ${filter === 'monitoring' ? 'highlight' : ''}" onclick="setBidPipelineFilter('monitoring')"><span>投标/开标</span><b>${monitoring}</b></div>
  1918. <div class="pool-stat ${filter === 'renewal' ? 'highlight' : ''}" onclick="setBidPipelineFilter('renewal')"><span>续标投标</span><b>${renewal}</b></div>
  1919. `;
  1920. document.getElementById('bid-pipeline-chips').innerHTML = [
  1921. ['all', '全部'], ['renewal', '续标'], ['preparing', '标书编制'], ['monitoring', '投标开标'],
  1922. ].map(([k, l]) => `<button type="button" class="chip ${filter === k ? 'active' : ''}" onclick="setBidPipelineFilter('${k}')">${l}</button>`).join('');
  1923. if (!list.length) {
  1924. document.getElementById('bid-pipeline-list').innerHTML = `<div class="empty-shell" style="border:none;padding:32px">
  1925. <strong>暂无投标项目</strong><p>在招标监测中将信息「转投标」后在此跟进</p>
  1926. <button type="button" class="btn btn-primary" style="margin-top:12px" onclick="navigate('tender-pool')">去招标监测 →</button></div>`;
  1927. return;
  1928. }
  1929. document.getElementById('bid-pipeline-list').innerHTML = list.map(item => {
  1930. const [label, tag] = bidStatusLabel(item.status);
  1931. const daysLeft = Math.ceil((new Date(item.deadline) - DEMO_TODAY) / 86400000);
  1932. return `<div class="pipeline-row">
  1933. <div>
  1934. <div class="pipeline-row-title">${item.title}</div>
  1935. <div class="pipeline-row-meta">
  1936. <span>${item.district}</span>
  1937. <span>预算 ${formatMoney(item.budget)}</span>
  1938. <span>截止 ${item.deadline}${daysLeft > 0 ? ' · 剩' + daysLeft + '天' : ''}</span>
  1939. <span>转入 ${item.convertedAt}</span>
  1940. ${item.linkedProject ? `<span>关联 ${item.linkedProject}</span>` : ''}
  1941. ${item.bidProgress != null && item.status === 'preparing' ? `<span>标书进度 ${item.bidProgress}%</span>` : ''}
  1942. </div>
  1943. </div>
  1944. <div class="pipeline-row-actions">
  1945. <span class="tag ${tag}">${label}</span>
  1946. ${item.type === 'renewal' ? '<span class="tag tag-renewal">续标</span>' : ''}
  1947. ${item.status === 'preparing' || item.status === 'ready' ? `<button type="button" class="btn btn-primary" style="font-size:12px;padding:4px 10px" onclick="openBidFromPipeline('${item.tenderId}')">标书编制 →</button>` : ''}
  1948. ${item.status === 'monitoring' || item.status === 'submitted' ? `<button type="button" class="btn btn-primary" style="font-size:12px;padding:4px 10px" onclick="navigate('bid-result');setResultTab('monitor')">开标监控 →</button>` : ''}
  1949. <button type="button" class="btn" style="font-size:12px;padding:4px 10px" onclick="openTenderDrawer('${item.tenderId}')">原公告</button>
  1950. </div>
  1951. </div>`;
  1952. }).join('');
  1953. }
  1954. function openBidFromPipeline(tenderId) {
  1955. if (tenderId === 't-renewal') {
  1956. navigate('bid-task');
  1957. return;
  1958. }
  1959. toast('标书编制功能演示 · 续标项目已完整接入');
  1960. navigate('bid-workbench');
  1961. }
  1962. function renderTenderReserveList() {
  1963. const search = (document.getElementById('reserve-search')?.value || '').toLowerCase();
  1964. const district = document.getElementById('reserve-district')?.value || '';
  1965. const filter = appState.reserveFilter;
  1966. let list = [...reserveProjects];
  1967. if (search) list = list.filter(x => (x.title + x.district + x.reason).toLowerCase().includes(search));
  1968. if (district) list = list.filter(x => x.district === district);
  1969. if (filter === 'evaluating') list = list.filter(x => x.status === 'evaluating');
  1970. else if (filter === 'watching') list = list.filter(x => x.status === 'watching');
  1971. else if (filter === 'high') list = list.filter(x => x.matchScore >= 80);
  1972. const evaluating = reserveProjects.filter(x => x.status === 'evaluating').length;
  1973. const watching = reserveProjects.filter(x => x.status === 'watching').length;
  1974. const high = reserveProjects.filter(x => x.matchScore >= 80).length;
  1975. document.getElementById('reserve-stats').innerHTML = `
  1976. <div class="pool-stat ${filter === 'all' ? 'highlight' : ''}" onclick="setReserveFilter('all')"><span>全部</span><b>${reserveProjects.length}</b></div>
  1977. <div class="pool-stat ${filter === 'evaluating' ? 'highlight' : ''}" onclick="setReserveFilter('evaluating')"><span>评估中</span><b>${evaluating}</b></div>
  1978. <div class="pool-stat ${filter === 'watching' ? 'highlight' : ''}" onclick="setReserveFilter('watching')"><span>观察中</span><b>${watching}</b></div>
  1979. <div class="pool-stat ${filter === 'high' ? 'highlight' : ''}" onclick="setReserveFilter('high')"><span>高匹配 ≥80</span><b>${high}</b></div>
  1980. `;
  1981. document.getElementById('reserve-chips').innerHTML = [
  1982. ['all', '全部'], ['evaluating', '评估中'], ['watching', '观察中'], ['high', '高匹配'],
  1983. ].map(([k, l]) => `<button type="button" class="chip ${filter === k ? 'active' : ''}" onclick="setReserveFilter('${k}')">${l}</button>`).join('');
  1984. if (!list.length) {
  1985. document.getElementById('reserve-list').innerHTML = `<div class="empty-shell" style="border:none;padding:32px">
  1986. <strong>暂无储备项目</strong><p>在招标监测中将信息「转储备」后在此评估跟进</p>
  1987. <button type="button" class="btn btn-primary" style="margin-top:12px" onclick="navigate('tender-pool')">去招标监测 →</button></div>`;
  1988. return;
  1989. }
  1990. document.getElementById('reserve-list').innerHTML = list.map(r => {
  1991. const [label, tag] = reserveStatusLabel(r.status);
  1992. const daysLeft = Math.ceil((new Date(r.deadline) - DEMO_TODAY) / 86400000);
  1993. return `<div class="pipeline-row">
  1994. <div>
  1995. <div class="pipeline-row-title">${r.title}</div>
  1996. <div class="pipeline-row-meta">
  1997. <span>${r.district}</span>
  1998. <span>预算 ${formatMoney(r.budget)}</span>
  1999. <span>匹配 ${r.matchScore}</span>
  2000. <span>截止 ${r.deadline}${daysLeft > 0 ? ' · 剩' + daysLeft + '天' : ''}</span>
  2001. <span>转入 ${r.convertedAt}</span>
  2002. <span>${r.owner}</span>
  2003. </div>
  2004. <div style="font-size:12px;color:var(--text2);margin-top:6px">${r.reason}</div>
  2005. </div>
  2006. <div class="pipeline-row-actions">
  2007. <span class="tag tag-reserve">${label}</span>
  2008. <button type="button" class="btn btn-primary" style="font-size:12px;padding:4px 10px" onclick="reserveToBid('${r.id}')">转投标 →</button>
  2009. <button type="button" class="btn" style="font-size:12px;padding:4px 10px" onclick="openTenderDrawer('${r.tenderId}')">原公告</button>
  2010. <button type="button" class="btn btn-ghost" style="font-size:12px;padding:4px 10px" onclick="removeFromReserve('${r.id}')">移出</button>
  2011. </div>
  2012. </div>`;
  2013. }).join('');
  2014. }
  2015. function setPoolFilter(f) {
  2016. appState.poolFilter = f;
  2017. renderTenderPool();
  2018. }
  2019. function openTenderDrawer(id) {
  2020. const t = tenderPool.find(x => x.id === id);
  2021. if (!t) return;
  2022. appState.selectedTenderId = id;
  2023. document.getElementById('drawer-title').textContent = t.title;
  2024. document.getElementById('drawer-sub').textContent = `${t.source} · ${t.district} · 发布 ${t.publishDate}${t.tenderNo ? ' · ' + t.tenderNo : ''}`;
  2025. const linked = t.linkedProjectId === 'renewal-demo' ? renewalDemo.displayName : '—';
  2026. const sourceUrl = t.sourceUrl || '';
  2027. document.getElementById('drawer-body').innerHTML = `
  2028. <div class="drawer-section">
  2029. <h4>原文链接</h4>
  2030. ${sourceUrl ? `
  2031. <div class="source-link-box">
  2032. <span style="font-size:16px;flex-shrink:0">🔗</span>
  2033. <div>
  2034. <div style="font-size:11px;color:var(--text3);margin-bottom:4px">${t.source} · 抓取源页面</div>
  2035. <a href="${sourceUrl}" target="_blank" rel="noopener noreferrer">${sourceUrl}</a>
  2036. </div>
  2037. </div>` : '<p style="font-size:13px;color:var(--text3)">暂无原文链接</p>'}
  2038. </div>
  2039. <div class="drawer-section">
  2040. <h4>基本信息</h4>
  2041. <dl class="info">
  2042. <dt>信息来源</dt><dd>${t.source}</dd>
  2043. <dt>招标编号</dt><dd>${t.tenderNo || '—'}</dd>
  2044. <dt>预算金额</dt><dd>${formatMoney(t.budget)}/年</dd>
  2045. <dt>投标截止</dt><dd>${t.deadline}</dd>
  2046. <dt>匹配得分</dt><dd><span class="score-badge ${t.matchScore >= 80 ? 'high' : ''}">${t.matchScore} 分</span></dd>
  2047. <dt>关联项目</dt><dd>${linked}</dd>
  2048. <dt>关键词</dt><dd>${t.keywords.join('、')}</dd>
  2049. </dl>
  2050. </div>
  2051. <div class="drawer-section">
  2052. <h4>公告摘要</h4>
  2053. <p style="font-size:13px;color:var(--text2)">${t.content}</p>
  2054. </div>
  2055. <div class="drawer-section">
  2056. <h4>智能评估</h4>
  2057. <p style="font-size:13px">${t.type === 'renewal' ? '🟢 与在管续标项目高度匹配,建议优先跟进' : t.matchScore >= 80 ? '🟡 业态匹配,可纳入储备' : '⚪ 匹配度一般,可观察'}</p>
  2058. </div>
  2059. <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:20px">
  2060. ${sourceUrl ? `<a class="btn btn-primary" href="${sourceUrl}" target="_blank" rel="noopener noreferrer">打开原公告链接 ↗</a>` : ''}
  2061. ${t.type === 'renewal' && !t.convertedTo ? `<button type="button" class="btn btn-primary" onclick="fetchTenderDoc('${t.id}')">获取标书并拆解</button>` : ''}
  2062. ${!t.convertedTo ? `<button type="button" class="btn btn-primary" onclick="convertToBid('${t.id}', true)">转投标</button>` : ''}
  2063. ${t.convertedTo === 'bid' ? `<button type="button" class="btn btn-primary" onclick="navigate('tender-bids')">查看投标项目 →</button>` : ''}
  2064. ${!t.convertedTo ? `<button type="button" class="btn" onclick="convertToReserve('${t.id}', true)">转储备项目</button>` : ''}
  2065. ${t.convertedTo === 'reserve' ? `<button type="button" class="btn" onclick="navigate('tender-reserve')">查看储备项目 →</button>` : ''}
  2066. <button type="button" class="btn btn-ghost" onclick="closeTenderDrawer()">关闭</button>
  2067. </div>
  2068. `;
  2069. document.getElementById('tender-drawer-mask').classList.remove('hidden');
  2070. renderTenderPool();
  2071. }
  2072. function closeTenderDrawer() {
  2073. document.getElementById('tender-drawer-mask').classList.add('hidden');
  2074. appState.selectedTenderId = null;
  2075. renderTenderPool();
  2076. }
  2077. function fetchTenderDoc(id, returnToRenewal) {
  2078. const t = tenderPool.find(x => x.id === id);
  2079. if (!t) return;
  2080. t.status = 'matched';
  2081. ensureBidPipelineItem(id);
  2082. appState.renewalTasks.find(x => x.id === 'r3').done = true;
  2083. appState.renewalTasks.find(x => x.id === 'r4').done = true;
  2084. appState.bidTaskFromTender = true;
  2085. bidBlocks.filter(b => b.type === 'auto').forEach(b => appState.confirmedBlocks.add(b.id));
  2086. appState.activeBlockId = bidBlocks.find(b => !appState.confirmedBlocks.has(b.id))?.id || 'b3';
  2087. closeTenderDrawer();
  2088. toast('招标文件已获取' + (returnToRenewal ? ' · 已返回项目续标' : '') + ' · 请确认剩余 ' + (bidBlocks.length - appState.confirmedBlocks.size) + ' 块');
  2089. renderDashboard();
  2090. renderProject();
  2091. renderMarket();
  2092. renderTenderBidList();
  2093. setTimeout(() => navigate(returnToRenewal ? 'renewal-detail' : 'bid-task'), 400);
  2094. }
  2095. function renderBidWorkbench() {
  2096. const days = daysUntil(renewalDemo.contractEnd);
  2097. const progress = Math.round((appState.confirmedBlocks.size / bidBlocks.length) * 100);
  2098. const autoDone = bidBlocks.filter(b => b.type === 'auto').length;
  2099. const pending = bidBlocks.length - appState.confirmedBlocks.size;
  2100. document.getElementById('bid-task-list').innerHTML = `
  2101. <div class="bid-task-row">
  2102. <div>
  2103. <strong>${renewalDemo.displayName} · 续标投标</strong>
  2104. <div style="font-size:12px;color:var(--text3);margin-top:4px">
  2105. 合同剩 ${days} 天 · ${appState.bidTaskFromTender ? '招标文件已关联 · 拆解 ' + bidBlocks.length + ' 块' : '待从招标监测获取'}
  2106. </div>
  2107. <div style="font-size:11px;color:var(--text3);margin-top:4px">
  2108. 🟢 自动 ${autoDone} 块 · 待确认 ${pending} 块 · 数据映射 ${bidBlocks.flatMap(b => b.mappings || []).filter(m => m.status === 'warn').length} 项
  2109. </div>
  2110. <div class="progress-bar" style="max-width:280px;margin-top:8px"><i style="width:${progress}%"></i></div>
  2111. </div>
  2112. <div>
  2113. <span class="tag tag-warn">${progress === 100 ? (appState.bidExported ? '已导出' : '待导出') : progress > 0 ? '确认中 ' + progress + '%' : appState.bidTaskFromTender ? '拆解完成' : '待拆解'}</span>
  2114. <button type="button" class="btn btn-primary" style="margin-left:8px" onclick="navigate('bid-task')">继续 →</button>
  2115. </div>
  2116. </div>
  2117. `;
  2118. }
  2119. function getBlockDraft(id) {
  2120. if (appState.blockDrafts[id] != null) return appState.blockDrafts[id];
  2121. const b = bidBlocks.find(x => x.id === id);
  2122. return b?.draft || '';
  2123. }
  2124. function updateBlockDraft(id, val) {
  2125. appState.blockDrafts[id] = val;
  2126. }
  2127. function escapeHtml(s) {
  2128. return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  2129. }
  2130. function getDraftStats(text) {
  2131. const chars = text.length;
  2132. const lines = text ? text.split(/\n/).length : 0;
  2133. return { chars, lines };
  2134. }
  2135. function regenerateBlockDraft(id) {
  2136. const b = bidBlocks.find(x => x.id === id);
  2137. if (!b) return;
  2138. appState.blockDrafts[id] = b.draft;
  2139. toast('已恢复 AI 生成草稿');
  2140. renderBidTask();
  2141. setTimeout(() => document.getElementById('block-draft-editor')?.focus(), 50);
  2142. }
  2143. function renderBlockRefPanel(block) {
  2144. if (!block) return '<p style="color:var(--text3);font-size:12px">请选择拆解块</p>';
  2145. const reqHtml = (block.requirements || []).map(r => `<li class="matched">${r}</li>`).join('');
  2146. const mapHtml = (block.mappings || []).map(m =>
  2147. `<tr><td>${m.field}</td><td>${m.source}</td><td>${m.value}</td><td class="${m.status === 'ok' ? 'mapping-ok' : 'mapping-warn'}">${m.status === 'ok' ? '✓' : '⚠'}</td></tr>`
  2148. ).join('');
  2149. const scoreHtml = block.scores
  2150. ? `<div class="score-grid">${block.scores.map(s => `<div class="score-item"><b>${s.pts}</b>${s.label}</div>`).join('')}</div>`
  2151. : '';
  2152. return `
  2153. <div class="bid-ref-scroll">
  2154. <div style="font-size:11px;color:var(--text3);margin-bottom:6px">提取要点 · ${block.docRef}</div>
  2155. <ul class="req-list">${reqHtml}</ul>
  2156. ${scoreHtml}
  2157. <div style="margin-top:10px;font-size:11px;font-weight:600;color:var(--text2)">数据映射</div>
  2158. <table class="mapping-table compact">
  2159. <thead><tr><th>字段</th><th>来源</th><th>值</th><th></th></tr></thead>
  2160. <tbody>${mapHtml}</tbody>
  2161. </table>
  2162. ${block.historyNote ? `<div class="history-compare" style="margin-top:10px;font-size:11px">📋 ${block.historyNote}</div>` : ''}
  2163. </div>`;
  2164. }
  2165. function renderBlockEditorPanel(block) {
  2166. if (!block) {
  2167. return `<div class="bid-editor-toolbar"><div class="bid-editor-toolbar-left"><h3>草稿编辑</h3></div></div>
  2168. <div class="bid-editor-body"><div class="bid-draft-readonly" style="color:var(--text3)">请从上方选择拆解块后开始编辑</div></div>`;
  2169. }
  2170. const confirmed = appState.confirmedBlocks.has(block.id);
  2171. const draft = getBlockDraft(block.id);
  2172. const editable = block.type === 'manual' || block.type === 'confirm';
  2173. const stats = getDraftStats(draft);
  2174. const [typeLabel, typeCls] = fillTypeLabel(block.type);
  2175. return `
  2176. <div class="bid-editor-toolbar">
  2177. <div class="bid-editor-toolbar-left">
  2178. <h3>${block.title}</h3>
  2179. <span class="${typeCls}" style="font-size:12px">${typeLabel}</span>
  2180. ${confirmed ? '<span class="tag">已确认</span>' : '<span class="tag tag-warn">待确认 · 请在下方编辑后确认</span>'}
  2181. </div>
  2182. <div class="bid-editor-toolbar-right">
  2183. ${editable && !confirmed ? `
  2184. <button type="button" class="btn" onclick="regenerateBlockDraft('${block.id}')">恢复 AI 草稿</button>
  2185. ${block.type === 'manual' ? `<button type="button" class="btn" onclick="toast('AI 已基于历史标书重新生成草稿');regenerateBlockDraft('${block.id}')">重新生成</button>` : ''}
  2186. <button type="button" class="btn btn-primary" onclick="confirmBlock('${block.id}')">确认本块</button>
  2187. ` : ''}
  2188. ${confirmed ? `<button type="button" class="btn" onclick="selectBlock('${block.id}');toast('已确认块如需修改请重新打开')">查看</button>` : ''}
  2189. </div>
  2190. </div>
  2191. <div class="bid-editor-body">
  2192. ${editable && !confirmed
  2193. ? `<textarea class="bid-draft-editor" id="block-draft-editor" placeholder="在此编辑本块投标内容…支持多段落,确认后将写入投标文件预览。" oninput="updateBlockDraft('${block.id}', this.value);updateDraftStats()">${escapeHtml(draft)}</textarea>`
  2194. : `<div class="bid-draft-readonly">${escapeHtml(draft)}</div>`}
  2195. </div>
  2196. <div class="bid-editor-foot">
  2197. <span>${editable && !confirmed ? '提示:编辑区约占页面 1/3 以上,可直接修改 AI 草稿内容' : '🟢 本块由系统自动填充,无需人工编辑'}</span>
  2198. <span id="draft-stats">${stats.chars} 字 · ${stats.lines} 行</span>
  2199. </div>`;
  2200. }
  2201. function updateDraftStats() {
  2202. const el = document.getElementById('block-draft-editor');
  2203. const statEl = document.getElementById('draft-stats');
  2204. if (!el || !statEl) return;
  2205. const stats = getDraftStats(el.value);
  2206. statEl.textContent = stats.chars + ' 字 · ' + stats.lines + ' 行';
  2207. const preview = document.getElementById('bid-preview-right');
  2208. if (preview && appState.activeBlockId) {
  2209. preview.innerHTML = buildPreview();
  2210. }
  2211. }
  2212. function renderBlockDetail(block) {
  2213. return renderBlockRefPanel(block);
  2214. }
  2215. function renderBidTask() {
  2216. const p = renewalDemo;
  2217. const progress = Math.round((appState.confirmedBlocks.size / bidBlocks.length) * 100);
  2218. const autoCount = bidBlocks.filter(b => b.type === 'auto').length;
  2219. const confirmCount = bidBlocks.filter(b => b.type === 'confirm').length;
  2220. const manualCount = bidBlocks.filter(b => b.type === 'manual').length;
  2221. const autoDone = bidBlocks.filter(b => b.type === 'auto' && appState.confirmedBlocks.has(b.id)).length;
  2222. const warnMappings = bidBlocks.flatMap(b => b.mappings || []).filter(m => m.status === 'warn').length;
  2223. document.getElementById('bid-task-title').textContent = `${p.displayName} · 续标投标`;
  2224. document.getElementById('bid-progress-bar').style.width = progress + '%';
  2225. document.getElementById('btn-export-word').disabled = progress < 100;
  2226. document.getElementById('btn-register-bid').disabled = !appState.bidExported;
  2227. const step = progress === 100 ? 4 : progress > 0 ? 3 : appState.bidTaskFromTender ? 2 : 1;
  2228. document.getElementById('bid-task-steps').innerHTML = `
  2229. <span class="step ${step >= 1 ? 'done' : ''}">1 上传${appState.bidTaskFromTender ? ' ✓' : ''}</span>
  2230. <span class="step ${step === 2 ? 'active' : step > 2 ? 'done' : ''}">2 智能拆解</span>
  2231. <span class="step ${step === 3 ? 'active' : step > 3 ? 'done' : ''}">3 分步确认 (${appState.confirmedBlocks.size}/${bidBlocks.length})</span>
  2232. <span class="step ${step === 4 ? 'active' : ''}">4 生成导出</span>
  2233. `;
  2234. document.getElementById('bid-summary-bar').innerHTML = `
  2235. <div class="bid-summary-item"><span>拆解进度</span><b>${progress}%</b></div>
  2236. <div class="bid-summary-item"><span>🟢 自动填充</span><b>${autoDone}/${autoCount}</b></div>
  2237. <div class="bid-summary-item"><span>🟡 需确认</span><b>${bidBlocks.filter(b => b.type === 'confirm' && appState.confirmedBlocks.has(b.id)).length}/${confirmCount}</b></div>
  2238. <div class="bid-summary-item"><span>⚠ 待处理映射</span><b>${warnMappings}</b></div>
  2239. `;
  2240. const activeBlock = bidBlocks.find(b => b.id === appState.activeBlockId);
  2241. document.getElementById('bid-doc-left').innerHTML = `
  2242. <div style="font-size:11px;color:var(--text3);margin-bottom:8px">招标文件 · 点击段落定位对应拆解块</div>
  2243. ${tenderDocLines.map((line, i) =>
  2244. `<div class="doc-line ${activeBlock && activeBlock.docLine === i ? 'active highlight' : ''}" onclick="highlightDocLine(${i})">${line}</div>`
  2245. ).join('')}
  2246. <div style="margin-top:12px;padding:10px;background:#f0fdf4;border-radius:6px;font-size:11px;color:#059669">
  2247. 已关联 ${bidBlocks.length} 个拆解块 · ${appState.bidTaskFromTender ? '招标文件已解析' : '待上传解析'}
  2248. </div>`;
  2249. document.getElementById('bid-blocks-mid').innerHTML = `
  2250. <div style="font-size:11px;color:var(--text3);margin-bottom:8px">点击切换块 · 在下方编辑区修改草稿</div>
  2251. <div class="bid-blocks-compact">
  2252. ${bidBlocks.map(b => {
  2253. const [label, cls] = fillTypeLabel(b.type);
  2254. const confirmed = appState.confirmedBlocks.has(b.id);
  2255. const warnCount = (b.mappings || []).filter(m => m.status === 'warn').length;
  2256. return `<div class="block-item compact ${b.id === appState.activeBlockId ? 'active' : ''} ${confirmed ? 'confirmed' : ''}" onclick="selectBlock('${b.id}')">
  2257. <strong>${b.title}</strong>
  2258. <div class="block-meta"><span class="${cls}">${label}</span>${warnCount ? ` · <span class="mapping-warn">${warnCount}项待核</span>` : ''}</div>
  2259. </div>`;
  2260. }).join('')}
  2261. </div>
  2262. ${renderBlockRefPanel(activeBlock)}
  2263. `;
  2264. const editorZone = document.getElementById('bid-editor-zone');
  2265. const editable = activeBlock && (activeBlock.type === 'manual' || activeBlock.type === 'confirm') && !appState.confirmedBlocks.has(activeBlock.id);
  2266. editorZone.className = 'bid-editor-zone' + (editable ? '' : ' readonly');
  2267. editorZone.innerHTML = renderBlockEditorPanel(activeBlock);
  2268. document.getElementById('bid-preview-right').innerHTML = buildPreview();
  2269. if (editable) {
  2270. setTimeout(() => {
  2271. const ta = document.getElementById('block-draft-editor');
  2272. if (ta) {
  2273. ta.focus();
  2274. ta.setSelectionRange(ta.value.length, ta.value.length);
  2275. }
  2276. }, 30);
  2277. }
  2278. }
  2279. function buildPreview() {
  2280. const p = renewalDemo;
  2281. let html = `<h4>上海申勤物业发展有限公司</h4>
  2282. <p style="font-size:11px;color:var(--text3)">投标文件(技术标 · 实时预览)</p>
  2283. <p style="font-size:11px;color:var(--text3);margin-bottom:12px">${p.displayName} · ${p.name}</p>`;
  2284. bidBlocks.forEach(b => {
  2285. const isActive = b.id === appState.activeBlockId;
  2286. const showInPreview = appState.confirmedBlocks.has(b.id) || b.type === 'auto' || isActive;
  2287. if (!showInPreview) return;
  2288. const draft = getBlockDraft(b.id);
  2289. const [label] = fillTypeLabel(b.type);
  2290. const activeMark = isActive && !appState.confirmedBlocks.has(b.id) ? ' · 编辑中' : '';
  2291. html += `<h4>${b.title} <span style="font-size:11px;font-weight:400;color:var(--text3)">${label}${activeMark}</span></h4>`;
  2292. html += `<p style="white-space:pre-wrap">${escapeHtml(isActive ? draft : draft.slice(0, 200))}${!isActive && draft.length > 200 ? '…' : ''}</p>`;
  2293. });
  2294. const pending = bidBlocks.filter(b => !appState.confirmedBlocks.has(b.id) && b.type !== 'auto');
  2295. if (pending.length) {
  2296. html += `<div style="margin-top:16px;padding:10px;background:#fff7ed;border-radius:6px;font-size:12px;color:#92400e">
  2297. 还有 ${pending.length} 块待确认:${pending.map(b => b.title).join('、')}
  2298. </div>`;
  2299. }
  2300. if (appState.confirmedBlocks.size === bidBlocks.length) {
  2301. html += '<p style="margin-top:16px;color:#059669;font-weight:600">✓ 全部块已确认,可导出 Word</p>';
  2302. }
  2303. return html;
  2304. }
  2305. function selectBlock(id) {
  2306. appState.activeBlockId = id;
  2307. renderBidTask();
  2308. }
  2309. function highlightDocLine(i) {
  2310. const block = bidBlocks.find(b => b.docLine === i);
  2311. if (block) appState.activeBlockId = block.id;
  2312. renderBidTask();
  2313. }
  2314. function confirmBlock(id) {
  2315. appState.confirmedBlocks.add(id);
  2316. const idx = bidBlocks.findIndex(b => b.id === id);
  2317. if (idx < bidBlocks.length - 1) appState.activeBlockId = bidBlocks[idx + 1].id;
  2318. appState.renewalTasks.find(x => x.id === 'r5').done = appState.confirmedBlocks.size === bidBlocks.length;
  2319. toast('已确认:' + bidBlocks.find(b => b.id === id).title);
  2320. renderBidTask();
  2321. renderBidWorkbench();
  2322. renderProject();
  2323. renderTenderBidList();
  2324. if (location.hash === '#renewal-detail') renderRenewalHub();
  2325. }
  2326. function confirmAllBlocks() {
  2327. confirmBlock(appState.activeBlockId);
  2328. }
  2329. function exportWord() {
  2330. if (appState.confirmedBlocks.size < bidBlocks.length) return;
  2331. appState.bidExported = true;
  2332. document.getElementById('btn-register-bid').disabled = false;
  2333. renderTenderBidList();
  2334. toast('Word 导出成功 · 请完成投标登记以开启开标监控');
  2335. }
  2336. function goRegisterBid() {
  2337. if (!appState.bidExported && appState.confirmedBlocks.size < bidBlocks.length) {
  2338. toast('请先完成标书确认并导出');
  2339. return;
  2340. }
  2341. appState.bidExported = true;
  2342. navigate('bid-result');
  2343. setResultTab('register');
  2344. prefillRegisterForm();
  2345. toast('请填写开标信息并开启监控');
  2346. }
  2347. function setResultTab(tab) {
  2348. appState.resultTab = tab;
  2349. document.querySelectorAll('.page-tab').forEach(t => {
  2350. t.classList.toggle('active', t.dataset.tab === tab);
  2351. });
  2352. document.getElementById('result-panel-register').classList.toggle('hidden', tab !== 'register');
  2353. document.getElementById('result-panel-monitor').classList.toggle('hidden', tab !== 'monitor');
  2354. document.getElementById('result-panel-win').classList.toggle('hidden', tab !== 'win');
  2355. renderBidResult();
  2356. }
  2357. function prefillRegisterForm() {
  2358. const t = getRenewalTender();
  2359. document.getElementById('reg-project').value = renewalDemo.displayName;
  2360. document.getElementById('reg-tender-no').value = t ? 'SH-GP-2026-0892' : '';
  2361. document.getElementById('reg-submit-date').value = '2026-06-20';
  2362. document.getElementById('reg-open-date').value = '2026-06-28';
  2363. document.getElementById('reg-open-time').value = '14:00';
  2364. document.getElementById('reg-bid-amount').value = '1668';
  2365. document.getElementById('reg-monitor-url').value = 'https://www.zfcg.sh.gov.cn/portal/detail?resultId=demo-0892';
  2366. }
  2367. function renderRegisterForm() {
  2368. if (appState.registerFormBuilt) return;
  2369. appState.registerFormBuilt = true;
  2370. document.getElementById('register-form').innerHTML = `
  2371. <label>项目名称</label><input type="text" id="reg-project" placeholder="关联在管/投标项目" />
  2372. <label>招标编号</label><input type="text" id="reg-tender-no" placeholder="如 SH-GP-2026-xxxx" />
  2373. <label>投标提交日</label><input type="date" id="reg-submit-date" />
  2374. <label>开标日期</label><input type="date" id="reg-open-date" />
  2375. <label>开标时间</label><input type="time" id="reg-open-time" value="14:00" />
  2376. <label>投标报价(万元/年)</label><input type="number" id="reg-bid-amount" placeholder="615" />
  2377. <label>结果监控 URL</label><input type="url" id="reg-monitor-url" placeholder="政府采购网开标结果页链接" />
  2378. <label>监控说明</label><textarea id="reg-note" placeholder="可选:备注包件、标段等">系统将每 30 分钟自动检查该 URL,开标结果发布后抓取并推送。</textarea>
  2379. `;
  2380. }
  2381. function submitBidRegistration() {
  2382. const project = document.getElementById('reg-project').value.trim();
  2383. const openDate = document.getElementById('reg-open-date').value;
  2384. if (!project || !openDate) { toast('请填写项目名称和开标日期'); return; }
  2385. const sub = {
  2386. id: 'sub-' + Date.now(),
  2387. projectName: project,
  2388. projectId: renewalDemo.id,
  2389. tenderNo: document.getElementById('reg-tender-no').value,
  2390. submitDate: document.getElementById('reg-submit-date').value,
  2391. openBidDate: openDate,
  2392. openBidTime: document.getElementById('reg-open-time').value || '14:00',
  2393. bidAmount: parseFloat(document.getElementById('reg-bid-amount').value) * 10000 || 6150000,
  2394. monitorUrl: document.getElementById('reg-monitor-url').value,
  2395. status: 'monitoring',
  2396. monitorSince: '2026-06-08 10:00',
  2397. lastCheck: '2026-06-08 10:30',
  2398. checkCount: 12,
  2399. result: null,
  2400. pushes: [],
  2401. timeline: [{ time: '2026-06-08 10:00', text: '投标登记完成,开标监控已开启' }],
  2402. };
  2403. appState.bidSubmissions = appState.bidSubmissions.filter(s => s.projectId !== renewalDemo.id);
  2404. appState.bidSubmissions.unshift(sub);
  2405. appState.selectedSubmissionId = sub.id;
  2406. appState.renewalTasks.find(x => x.id === 'r6').done = true;
  2407. toast('投标已登记 · 开标监控已开启 · 距开标 ' + daysUntilOpen(sub) + ' 天');
  2408. setResultTab('monitor');
  2409. renderDashboard();
  2410. renderMarket();
  2411. renderProject();
  2412. renderTenderBidList();
  2413. if (location.hash === '#renewal-detail') renderRenewalHub();
  2414. }
  2415. function daysUntilOpen(sub) {
  2416. if (!sub?.openBidDate) return '—';
  2417. return Math.ceil((new Date(sub.openBidDate) - DEMO_TODAY) / 86400000);
  2418. }
  2419. function renderMonitorList() {
  2420. const list = appState.bidSubmissions;
  2421. const monitoring = list.filter(s => s.status === 'monitoring').length;
  2422. const fetched = list.filter(s => s.status === 'result_fetched').length;
  2423. const pushed = list.filter(s => s.status === 'pushed').length;
  2424. document.getElementById('monitor-stats').innerHTML = `
  2425. <div class="pool-stat ${monitoring ? 'highlight' : ''}"><span>监控中</span><b>${monitoring}</b></div>
  2426. <div class="pool-stat"><span>待登记结果</span><b>${fetched}</b></div>
  2427. <div class="pool-stat"><span>已推送</span><b>${pushed}</b></div>
  2428. `;
  2429. document.getElementById('monitor-tab-badge').textContent = monitoring ? ` (${monitoring})` : '';
  2430. document.getElementById('btn-simulate-result').style.display = monitoring ? 'inline-block' : 'none';
  2431. if (!list.length) {
  2432. document.getElementById('monitor-list').innerHTML = `
  2433. <div class="empty-shell" style="border:none;padding:24px">
  2434. <strong>暂无监控任务</strong>
  2435. <p>请先在「投标登记」中提交投标信息</p>
  2436. <button type="button" class="btn btn-primary" style="margin-top:12px" onclick="setResultTab('register')">去登记 →</button>
  2437. </div>`;
  2438. return;
  2439. }
  2440. document.getElementById('monitor-list').innerHTML = list.map(s => {
  2441. const statusMap = {
  2442. monitoring: ['监控中', 'tag-warn', true],
  2443. result_fetched: ['结果已抓取', 'tag-danger', false],
  2444. registered: ['待推送', 'tag-warn', false],
  2445. pushed: ['已推送', 'tag', false],
  2446. };
  2447. const [label, tagCls, pulsing] = statusMap[s.status] || ['—', 'tag', false];
  2448. return `<div class="monitor-card ${s.status === 'result_fetched' ? 'won' : ''} ${appState.selectedSubmissionId === s.id ? 'active' : ''}" onclick="selectSubmission('${s.id}')">
  2449. <div class="monitor-head">
  2450. <strong>${s.projectName}</strong>
  2451. <span class="tag ${tagCls}">${label}</span>
  2452. </div>
  2453. <div class="monitor-meta">
  2454. <span>招标编号 ${s.tenderNo || '—'}</span>
  2455. <span>开标 ${s.openBidDate} ${s.openBidTime}</span>
  2456. <span>报价 ${formatMoney(s.bidAmount)}</span>
  2457. ${s.status === 'monitoring' ? `<span>距开标 <strong>${daysUntilOpen(s)}</strong> 天</span>` : ''}
  2458. </div>
  2459. <div class="monitor-status">
  2460. ${pulsing ? '<span class="pulse-dot"></span>' : ''}
  2461. <span>最近检查 ${s.lastCheck} · 已检查 ${s.checkCount} 次</span>
  2462. </div>
  2463. ${s.result ? `<div style="margin-top:10px;font-size:13px;color:#059669"><strong>${s.result.won ? '中标' : '未中标'}</strong> · 得分 ${s.result.score} · 排名 ${s.result.rank}</div>` : ''}
  2464. <div style="margin-top:10px;display:flex;gap:6px;flex-wrap:wrap" onclick="event.stopPropagation()">
  2465. ${s.status === 'monitoring' ? `<button type="button" class="btn btn-primary" style="font-size:12px;padding:4px 10px" onclick="simulateOpenBidResult('${s.id}')">模拟结果发布</button>` : ''}
  2466. ${s.status === 'result_fetched' ? `<button type="button" class="btn btn-primary" style="font-size:12px;padding:4px 10px" onclick="goWinRegister('${s.id}')">中标登记 →</button>` : ''}
  2467. ${s.status === 'pushed' ? `<button type="button" class="btn" style="font-size:12px;padding:4px 10px" onclick="goWinRegister('${s.id}')">查看推送</button>` : ''}
  2468. </div>
  2469. </div>`;
  2470. }).join('');
  2471. }
  2472. function selectSubmission(id) {
  2473. appState.selectedSubmissionId = id;
  2474. renderMonitorList();
  2475. }
  2476. function simulateOpenBidResult(id) {
  2477. const sub = appState.bidSubmissions.find(s => s.id === (id || appState.selectedSubmissionId || appState.bidSubmissions.find(x => x.status === 'monitoring')?.id));
  2478. if (!sub || sub.status !== 'monitoring') { toast('没有监控中的投标'); return; }
  2479. sub.status = 'result_fetched';
  2480. sub.lastCheck = '2026-06-08 10:35';
  2481. sub.checkCount += 1;
  2482. sub.result = { won: true, score: 92.5, rank: 1, publishDate: '2026-06-08', winner: '上海申勤物业发展有限公司' };
  2483. sub.timeline.push({ time: '2026-06-08 10:35', text: '系统自动抓取开标结果:中标,得分 92.5' });
  2484. appState.selectedSubmissionId = sub.id;
  2485. toast('开标结果已抓取:中标!请完成中标登记与推送');
  2486. renderMonitorList();
  2487. renderDashboard();
  2488. renderMarket();
  2489. renderProject();
  2490. renderTenderBidList();
  2491. if (location.hash === '#renewal-detail') renderRenewalHub();
  2492. setTimeout(() => { setResultTab('win'); goWinRegister(sub.id); }, 500);
  2493. }
  2494. function goWinRegister(id) {
  2495. appState.selectedSubmissionId = id;
  2496. setResultTab('win');
  2497. renderWinPanel();
  2498. }
  2499. function renderWinPanel() {
  2500. const sub = appState.bidSubmissions.find(s => s.id === appState.selectedSubmissionId);
  2501. const el = document.getElementById('win-content');
  2502. if (!sub) {
  2503. el.innerHTML = `<div class="empty-shell"><strong>暂无待登记的中标结果</strong><p>请先完成投标登记并等待开标结果抓取</p>
  2504. <button type="button" class="btn" onclick="setResultTab('monitor')">查看监控 →</button></div>`;
  2505. return;
  2506. }
  2507. if (!sub.result) {
  2508. el.innerHTML = `<div class="empty-shell"><strong>${sub.projectName}</strong><p>开标 ${sub.openBidDate} · 监控中,结果尚未发布</p>
  2509. <button type="button" class="btn btn-primary" onclick="simulateOpenBidResult('${sub.id}')">模拟结果发布</button></div>`;
  2510. return;
  2511. }
  2512. const won = sub.result.won;
  2513. el.innerHTML = `
  2514. <div class="result-banner ${won ? '' : 'lost'}">
  2515. <strong>${won ? '🎉 中标' : '未中标'}</strong> · ${sub.projectName}
  2516. <div style="font-size:13px;margin-top:6px;color:var(--text2)">
  2517. 得分 ${sub.result.score} · 排名 ${sub.result.rank} · 结果发布 ${sub.result.publishDate}
  2518. ${won ? ' · 中标单位:' + sub.result.winner : ''}
  2519. </div>
  2520. </div>
  2521. ${won && sub.status !== 'pushed' ? `
  2522. <div class="card">
  2523. <div class="card-head">中标登记确认</div>
  2524. <div class="card-body">
  2525. <div class="form-grid">
  2526. <label>中标日期</label><input type="date" id="win-date" value="${sub.result.publishDate}" />
  2527. <label>中标得分</label><input type="text" id="win-score" value="${sub.result.score}" />
  2528. <label>中标金额(万元/年)</label><input type="number" id="win-amount" value="${(sub.bidAmount/10000).toFixed(0)}" />
  2529. <label>承接情况</label><select id="win-type"><option value="存续">存续(续标)</option><option value="新增">新增</option></select>
  2530. <label>备注</label><textarea id="win-note">续标项目,合同衔接原公安分局机关办公区物业管理。</textarea>
  2531. </div>
  2532. <button type="button" class="btn btn-primary" style="margin-top:16px" onclick="confirmWinAndPush('${sub.id}')">确认中标 · 一键推送</button>
  2533. </div>
  2534. </div>` : ''}
  2535. ${sub.status === 'pushed' ? `
  2536. <div class="card">
  2537. <div class="card-head">部门推送状态 <span class="tag" style="float:right">已完成</span></div>
  2538. <div class="card-body">
  2539. <div class="push-grid">
  2540. ${sub.pushes.map(p => `<div class="push-item done"><strong>${p.dept}</strong>${p.action}<br><span style="color:var(--text3)">${p.time}</span></div>`).join('')}
  2541. </div>
  2542. </div>
  2543. </div>` : `
  2544. <div class="card" id="push-preview-card" style="${sub.status === 'pushed' ? '' : 'opacity:0.6'}">
  2545. <div class="card-head">推送预览(确认后发送)</div>
  2546. <div class="card-body">
  2547. <div class="push-grid">
  2548. <div class="push-item"><strong>运营部</strong>前介方案待办</div>
  2549. <div class="push-item"><strong>人力资源</strong>招聘需求草稿</div>
  2550. <div class="push-item"><strong>财务部</strong>收入确认 · 开票日历</div>
  2551. <div class="push-item"><strong>条线主管</strong>定编定岗通知</div>
  2552. </div>
  2553. </div>
  2554. </div>`}
  2555. <div class="card">
  2556. <div class="card-head">流程时间线</div>
  2557. <div class="card-body"><ul class="timeline">${sub.timeline.map(t => `<li class="done">${t.time} · ${t.text}</li>`).join('')}</ul></div>
  2558. </div>
  2559. `;
  2560. }
  2561. function confirmWinAndPush(id) {
  2562. const sub = appState.bidSubmissions.find(s => s.id === id);
  2563. if (!sub) return;
  2564. const now = '2026-06-08 10:40';
  2565. sub.status = 'pushed';
  2566. sub.pushes = [
  2567. { dept: '运营部', action: '前介看板新增卡片', time: now },
  2568. { dept: '人力资源', action: '招聘联动 · 缺口草稿', time: now },
  2569. { dept: '财务部', action: '收入科目 · 开票节点', time: now },
  2570. { dept: '条线主管', action: '定编方案待确认', time: now },
  2571. ];
  2572. sub.timeline.push(
  2573. { time: now, text: '市场部确认中标登记' },
  2574. { time: now, text: '已推送运营 / HR / 财务 / 条线(4 部门)' },
  2575. );
  2576. appState.renewalTasks.find(x => x.id === 'r7').done = true;
  2577. toast('中标已登记 · 已推送 4 个职能部门');
  2578. renderWinPanel();
  2579. renderMonitorList();
  2580. renderDashboard();
  2581. renderMarket();
  2582. renderProject();
  2583. renderTenderBidList();
  2584. if (location.hash === '#renewal-detail') renderRenewalHub();
  2585. }
  2586. function renderBidResult() {
  2587. if (!document.getElementById('register-form').innerHTML) renderRegisterForm();
  2588. renderMonitorList();
  2589. renderWinPanel();
  2590. }
  2591. function renderGisList(filter) {
  2592. filter = (filter || '').toLowerCase();
  2593. const items = [{ ...renewalDemo, renew: true }, ...otherProjects.map(p => ({ ...p, renew: false }))];
  2594. const filtered = items.filter(p => !filter || p.displayName.includes(filter) || (p.address && p.address.includes(filter)));
  2595. document.getElementById('gis-count').textContent = `共 ${filtered.length} 条`;
  2596. document.getElementById('gis-list').innerHTML = filtered.map(p => `
  2597. <button type="button" class="gis-item ${appState.gisSelectedId === (p.id || 'renewal-demo') ? 'on' : ''}" onclick="openGisProject('${p.id || 'renewal-demo'}')">
  2598. <b>${p.displayName} ${p.renew ? '<span class="renew-badge">· 续标 ' + daysUntil(renewalDemo.contractEnd) + '天</span>' : ''}</b>
  2599. <span>${p.address || '青浦区'}</span>
  2600. </button>
  2601. `).join('');
  2602. }
  2603. function openGisProject(id) {
  2604. appState.gisSelectedId = id;
  2605. const p = id === 'renewal-demo' ? renewalDemo : otherProjects.find(x => x.id === id) || renewalDemo;
  2606. const days = p.contractEnd ? daysUntil(p.contractEnd) : '—';
  2607. document.getElementById('gis-card-title').textContent = p.displayName;
  2608. document.getElementById('gis-card-body').innerHTML = `
  2609. <dt>甲方名称</dt><dd>${p.clientName || p.displayName}</dd>
  2610. <dt>项目地址</dt><dd>${p.address || '—'}</dd>
  2611. <dt>合同起止</dt><dd>${p.contractPeriod || p.contractEnd || '—'}${p.id === renewalDemo.id ? ` <span class="tag tag-danger">剩 ${days} 天</span>` : ''}</dd>
  2612. <dt>在管服务</dt><dd>${p.serviceScope || '—'}</dd>
  2613. <dt>金蝶编码</dt><dd>${p.kingdeeCode || '—'}</dd>
  2614. `;
  2615. document.getElementById('gis-btn-renewal').onclick = () => { closeGisCard(); startRenewal(); };
  2616. document.getElementById('gis-card').classList.remove('hidden');
  2617. document.querySelectorAll('.pin-project').forEach(pin => {
  2618. pin.style.transform = pin.dataset.id === id ? 'translate(-50%,-50%) scale(1.3)' : 'translate(-50%,-50%) scale(1)';
  2619. pin.style.zIndex = pin.dataset.id === id ? '5' : '2';
  2620. });
  2621. renderGisList(document.getElementById('gis-search')?.value);
  2622. }
  2623. function closeGisCard() {
  2624. document.getElementById('gis-card').classList.add('hidden');
  2625. }
  2626. function startRenewal() {
  2627. openRenewalDetail('renewal-demo');
  2628. }
  2629. const routes = {
  2630. dashboard: { view: 'view-dashboard', nav: 'dashboard', render: renderDashboard },
  2631. gis: { view: 'view-gis', nav: 'gis', render: () => { renderGisList(); updateGisClock(); bindGisOnce(); }, fullscreen: true },
  2632. projects: { view: 'view-projects', nav: 'projects', render: renderProjectList },
  2633. project: { view: 'view-project', nav: 'projects', render: renderProject },
  2634. renewal: { view: 'view-renewal', nav: 'renewal', render: renderRenewalList },
  2635. 'renewal-detail': { view: 'view-renewal-detail', nav: 'renewal', render: renderRenewalHub },
  2636. market: { view: 'view-market', nav: 'market', render: renderMarket },
  2637. 'tender-pool': { view: 'view-tender-pool', nav: 'tender-pool', render: renderTenderPool },
  2638. 'tender-bids': { view: 'view-tender-bids', nav: 'tender-pool', render: renderTenderBidList },
  2639. 'tender-reserve': { view: 'view-tender-reserve', nav: 'tender-pool', render: renderTenderReserveList },
  2640. 'bid-workbench': { view: 'view-bid-workbench', nav: 'bid-workbench', render: renderBidWorkbench },
  2641. 'bid-task': { view: 'view-bid-task', nav: 'bid-workbench', render: renderBidTask },
  2642. 'bid-result': { view: 'view-bid-result', nav: 'bid-result', render: () => setResultTab(appState.resultTab || 'register') },
  2643. };
  2644. function navigate(name) {
  2645. location.hash = name;
  2646. }
  2647. function updateGisClock() {
  2648. const el = document.getElementById('gis-clock');
  2649. if (el) el.textContent = new Date().toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' });
  2650. }
  2651. let gisBound = false;
  2652. function bindGisOnce() {
  2653. if (gisBound) return;
  2654. gisBound = true;
  2655. document.getElementById('gis-search')?.addEventListener('input', e => renderGisList(e.target.value));
  2656. document.getElementById('layer-renew')?.addEventListener('change', e => {
  2657. document.getElementById('gis-zone-renew').style.opacity = e.target.checked ? '1' : '0.2';
  2658. document.querySelectorAll('.pin-project.renew').forEach(p => { p.style.opacity = e.target.checked ? '1' : '0.35'; });
  2659. });
  2660. document.querySelectorAll('.pin-project').forEach(pin => {
  2661. pin.onclick = () => openGisProject(pin.dataset.id);
  2662. });
  2663. }
  2664. function route() {
  2665. const hash = (location.hash || '#dashboard').slice(1);
  2666. const r = routes[hash] || routes.dashboard;
  2667. document.querySelectorAll('.view, .view-gis').forEach(v => v.classList.add('hidden'));
  2668. document.getElementById('app-shell').classList.toggle('hidden', !!r.fullscreen);
  2669. document.getElementById(r.view)?.classList.remove('hidden');
  2670. document.querySelectorAll('#main-nav a[data-route]').forEach(a => {
  2671. a.classList.toggle('active', a.dataset.route === r.nav);
  2672. });
  2673. if (r.render) r.render();
  2674. }
  2675. window.addEventListener('hashchange', route);
  2676. window.addEventListener('load', route);
  2677. </script>
  2678. </body>
  2679. </html>