From 507fb9789a454c57c526f2e792cbb53d84a1c5c7 Mon Sep 17 00:00:00 2001 From: Armor00 <2654988228@qq.com> Date: Fri, 8 May 2026 15:16:04 +0800 Subject: [PATCH] update --- .dockerignore | 6 + .env.example | 17 + .gitignore | 9 + .idea/misc.xml | 3 + Dockerfile | 15 + KSP Engine Tweak Chart.xlsx | Bin 0 -> 114320 bytes README.md | 105 + Timeline.xlsx | Bin 0 -> 13165 bytes app/__init__.py | 39 + app/config.py | 42 + app/extensions.py | 6 + app/models.py | 187 + app/routes/__init__.py | 1 + app/routes/api.py | 32 + app/routes/web.py | 3768 +++++++++++++++++ app/services/__init__.py | 1 + app/services/fuel_conversion.py | 80 + app/services/log_book_importer.py | 278 ++ app/services/workbook_importer.py | 316 ++ app/services/workbook_inspector.py | 138 + app/static/styles.css | 2815 ++++++++++++ app/templates/_datetime_picker_field.html | 12 + app/templates/_mission_ops_tabs.html | 9 + app/templates/_mission_preview_controls.html | 40 + app/templates/_mission_preview_header.html | 40 + app/templates/_mission_upcoming_events.html | 23 + app/templates/asset_detail.html | 185 + app/templates/asset_entry_form.html | 264 ++ app/templates/asset_list.html | 114 + app/templates/base.html | 284 ++ app/templates/communication_list.html | 148 + app/templates/dashboard.html | 80 + app/templates/engine_create.html | 97 + app/templates/engine_detail.html | 109 + app/templates/engine_edit.html | 247 ++ app/templates/engine_list.html | 262 ++ app/templates/fuel_converter.html | 109 + app/templates/mission_asset_log_preview.html | 113 + app/templates/mission_list.html | 137 + .../mission_location_board_preview.html | 106 + .../mission_status_board_preview.html | 111 + app/templates/mission_timeline_preview.html | 240 ++ app/templates/resource_edit.html | 89 + app/templates/tank_list.html | 146 + app/templates/vehicle_list.html | 127 + docker-compose.yml | 10 + job.md | 82 + log_book.xlsx | Bin 0 -> 26884 bytes main.py | 12 + migrations/README | 1 + migrations/alembic.ini | 50 + migrations/env.py | 113 + migrations/script.py.mako | 24 + ...bb2156c_add_detail_to_asset_state_nodes.py | 26 + .../6c2a57f2b1e9_add_asset_log_tables.py | 71 + ...6d_expand_communication_range_precision.py | 46 + ...8bbf69ea2013_add_retired_flag_to_assets.py | 29 + .../versions/f1fa035d3afe_initial_schema.py | 154 + .../f4f0c9c9426e_add_asset_state_nodes.py | 41 + project_discovery.md | 53 + prototypes/mission-ops/asset-log.html | 316 ++ prototypes/mission-ops/index.html | 206 + prototypes/mission-ops/location-board.html | 260 ++ prototypes/mission-ops/script.js | 629 +++ prototypes/mission-ops/status-board.html | 398 ++ prototypes/mission-ops/styles.css | 1084 +++++ prototypes/mission-ops/timeline-view.html | 281 ++ requirements.txt | 7 + scripts/import_log_book.py | 34 + scripts/import_workbook.py | 36 + scripts/inspect_workbook.py | 31 + 71 files changed, 14944 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 KSP Engine Tweak Chart.xlsx create mode 100644 README.md create mode 100644 Timeline.xlsx create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/extensions.py create mode 100644 app/models.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/api.py create mode 100644 app/routes/web.py create mode 100644 app/services/__init__.py create mode 100644 app/services/fuel_conversion.py create mode 100644 app/services/log_book_importer.py create mode 100644 app/services/workbook_importer.py create mode 100644 app/services/workbook_inspector.py create mode 100644 app/static/styles.css create mode 100644 app/templates/_datetime_picker_field.html create mode 100644 app/templates/_mission_ops_tabs.html create mode 100644 app/templates/_mission_preview_controls.html create mode 100644 app/templates/_mission_preview_header.html create mode 100644 app/templates/_mission_upcoming_events.html create mode 100644 app/templates/asset_detail.html create mode 100644 app/templates/asset_entry_form.html create mode 100644 app/templates/asset_list.html create mode 100644 app/templates/base.html create mode 100644 app/templates/communication_list.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/engine_create.html create mode 100644 app/templates/engine_detail.html create mode 100644 app/templates/engine_edit.html create mode 100644 app/templates/engine_list.html create mode 100644 app/templates/fuel_converter.html create mode 100644 app/templates/mission_asset_log_preview.html create mode 100644 app/templates/mission_list.html create mode 100644 app/templates/mission_location_board_preview.html create mode 100644 app/templates/mission_status_board_preview.html create mode 100644 app/templates/mission_timeline_preview.html create mode 100644 app/templates/resource_edit.html create mode 100644 app/templates/tank_list.html create mode 100644 app/templates/vehicle_list.html create mode 100644 docker-compose.yml create mode 100644 log_book.xlsx create mode 100644 main.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/0f1b9bb2156c_add_detail_to_asset_state_nodes.py create mode 100644 migrations/versions/6c2a57f2b1e9_add_asset_log_tables.py create mode 100644 migrations/versions/83be4d38636d_expand_communication_range_precision.py create mode 100644 migrations/versions/8bbf69ea2013_add_retired_flag_to_assets.py create mode 100644 migrations/versions/f1fa035d3afe_initial_schema.py create mode 100644 migrations/versions/f4f0c9c9426e_add_asset_state_nodes.py create mode 100644 prototypes/mission-ops/asset-log.html create mode 100644 prototypes/mission-ops/index.html create mode 100644 prototypes/mission-ops/location-board.html create mode 100644 prototypes/mission-ops/script.js create mode 100644 prototypes/mission-ops/status-board.html create mode 100644 prototypes/mission-ops/styles.css create mode 100644 prototypes/mission-ops/timeline-view.html create mode 100644 requirements.txt create mode 100644 scripts/import_log_book.py create mode 100644 scripts/import_workbook.py create mode 100644 scripts/inspect_workbook.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cc17c13 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.venv +__pycache__ +.idea +.env +instance diff --git a/.env.example b/.env.example index e69de29..beb2092 100644 --- a/.env.example +++ b/.env.example @@ -0,0 +1,17 @@ +# .env.example - 供 LLM 或示例使用的安全示例值(无敏感信息) +# LLM 可以直接参考下面的键和值格式来推断或生成连接字符串。 + +PGHOST=127.0.0.1 +PGPORT=5432 +PGUSER=example_user +PGPASSWORD=example_password +PGDATABASE=example_db + +# 额外示例变量: +APP_ENV=development +APP_PORT=8000 +APP_SECRET_KEY=change-me +WORKBOOK_PATH=KSP Engine Tweak Chart.xlsx +SQLALCHEMY_ECHO=false + +# 说明:此文件为示例,不包含真实凭据。将真实凭据写入本地的 .env 文件并确保其被 .gitignore 忽略。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7359121 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.py[cod] +*.sqlite3 +instance/ +.env +.idea/ +.pytest_cache/ +.mypy_cache/ diff --git a/.idea/misc.xml b/.idea/misc.xml index 932a1c3..c3df08d 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,7 @@ + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..179cdcd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "main:app"] diff --git a/KSP Engine Tweak Chart.xlsx b/KSP Engine Tweak Chart.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..340f6073e0e3fbff85180e791fb4b5dc9a33d2ea GIT binary patch literal 114320 zcmeEsgOe}8_ubmIZQI^`YwNA8x3+D4*4A6ww)xh!ZClyjH%TQ`$$yYc)y!1&RCiC! zx%b>YXIfDP6buar2ng!uBnHwrq6KaO1_C050Rlq(Y0(z7vvoGLb=Ft)us3zmWpKB# zo=BON-)BM^zLtDPh_9h?Fnp~k##=drlB`8ExcFndSVun%bh*yzkS9_xr>qpAV?_Ss zy=_O4U>=&SbHjsmPTBPGY_FRW0V3s5pmU=b#hu%V_qm2EFhOe2 zH%nkOi!=mFt;9J)Qz-&A&AT6`_3I;pf`x`(4K*$-s4rrn57nKfY#ym#mk_IhQetQu z+fOf!Pm3|KVyJDZ9D_#Oyom%Nd3x)^Tsic1#ga)_u!&CBP&OBi18AY0hGIPClpvgo z8O^cC93*T#z1vjH!31JpSbXDnLD1pERbcvz{wl>!q8=iWH~B=oDXSxk`xRyGfjRh3 zlb*@?+yRPORsC1C7r;*-nuQUP7vl78ykFZ9?&1>(9bCtVS;u0P&{y1t!iD8LRHGc@j z45!uMcohCli`!?>9&+6QklKF!;jz~$Lrj9m4&4mt{+Fozgry#dtiCpjSp9LYh*Uvr zdkU*Pe%xPkm;OGS(-RyB==&QKNbx@?L;Mevo79*{AAZo41pxwr1%d+6cQmzjVr2NQ zD^Wfe2vsi7&x8Me{J;DKDih=s2AJSNuO&aiMxPfqzFXqzGN1cq!SF628t%)CBanw{Sxc}4|(^q|h{xuLx~ zwO42{`XK8*q^4XhBN2sjz*CrJC4ISa5HICI-UcIjr*aH>*EH`e!>oe5IJvWar@`2I z;htMgO0~pBil&>TUowEZ*Uqusf@A651C|rH+4H4{)VU)tg;`_nM;R-PjOp3mEj9Lm z29SO|5*aO)mxf)!KQ}X+IR@vwYEmldoR3+EeU0rPK1hOds=<1N1|Dfa`*i6vq=>2u z_EC|mHDacAHmkhGlJACAP^F6wLtzJ9a~VxT#pC;l-1MW5k?kmYbOL+Is^xJ-Ae?+t zRFtcdt_GTd37CW$9Z=d@z13G9?UWV!^x2GQN%7pauma#Db`zrJE6pX^A}Ha=Xj!!| zPAp}#2z)vtj|Exh)(UqxSPZ%e)AXibIIu6^b^WL%0y*rMX~)Xrk!ldQ7H!sPi+@<@ zPC&8MgDaYi4NN?p7&FaTam~-3Yy5n^pIX*P=>#rfzJ)cjBKb<=!qHb2uRjZc%#Q3V zJo?hXdK3y+sV32V{Y8j3S@|_VE_50Him-32o3GoupoLVUP{tIei9?}Mg*y+;+Z?Rq zV7In^f|D3F|6OW5e9Zvrzmnw0TZ7a-c5ho1&e)!_4%^lIYr#>!Ck72qt1e4fkG5Q| zJH$cRjtUZ|2-_WQF;v=7d{DQl^oy?O5WcGZ&4G+o66xdg@UCvXGY@-OqJDRcC%m%z zQ~E>~A>2g~70~A#U`-~N2@JErltD!R13J`bBj>6FNFtz z5_-F(!sH1v+$}PfAd+#AcAEncy+F_4q;DKq@eT@KQ8LSJE||c&q5g+K?yUG4EOc!t zqU-u#U@^p5&M^bSL?1D&Y0(Lxx2eH5aCc(kUpr)dj}*Ki+SIJ>uUy1tm`yHaPV^8% zFtv$6>!7zM^>dnShNyD9Eeh4>Lfxt;o}nSNnjP**77}@PkB|KR9=Jf@J4inS*=w=O zbiXD>BB$x)4t|Vo?e}*!Cj5OHJP_u01UJ6bmp3LMyM5@F)#pa?&*9n0fC69fJa5HB zGz6^Mv$q-1wX+9e8Q-OwTj);1W)&>Z(FG8Rd)Z%6@G{K03cq#cXObhZPwNf`mGHET z5F*D@zcGHtU4`Frw(tZfJ0Bw5s(FUn^cZ1wL_O%2&zMw!=twSRKy`)IiRivpeMi54TW zy8K4z3_O8#EVOs@Dcm{Oe|`9nTy1do^h(5|#mfp)vhVb^_rU&O1_;{dlzwN-sPzps z6qL2+jlB^5aMb_FVqifx1m-{dM*XwC!};$l_TQ`h|ITIqsr;SE>$XAv#rTw$_=;Cy zdB^faT`O#5E!xjON=%UGC}`$k-Jf*rO`?_!x#R0koH%*V^gGi=(^7jl|^p&-@y)W{)Dll(wsAXZX& ze-!Vf%&h70!#Kjk%ey9!#g*7V-#bDz+$%XpMYVWq2it0mUk3>u@xdNGl_`%qbGn7c)-XDF_{lyUYn>=ox-7*OS(;gFaI~ z^-Vy8rh8;Zc;Vx?mAZ50Ji#>8Dfsc!Vyo0(cfP!@H^e*(=5$$zTKwI*O@q4_9XuX& zG)OCI;qTHaFexH{(fn!;kuM{JInlW}XZ*jy;|xZDrEmfS#JdUxg!R9QjFW|_sk0N~ ze{RhGsWv{kP8;I&hwtJSeu_7nRl}UQwcf^(k&{OyR0%mAi=zt~4)kNRV>#&^b>BV; z==C_GiJG}9%sBemn2?%Ox$@?wrs`&&FZ$naFY670gx%hsH?Q;KK}Jmi?oT(To5%VB z-ftu0LGK-jgl|FS`aNBr5990G0)C$E9yixvHIUzr^WR_kf?xOdso!63=K}*R<`%p2 z%c+n(zVA=(QODCg-S3aP`|C9o(>;RTAa`H)sj+rFyWgL8vB&y;-*-1psZuo>xXZw%WO+rFK0Uuo4*Snkbb-R=AWqrYqFZQOV+s$XT z=iAGlY4@KAw|u@{cr#w!!?p#y-`%Vqc2-B+7@NOe9$v@Sd%i=TzkVzA_&i_e>)$9u zd{@jF@44I;#{Tqr{dl~8x1(+R@V;p9`P_ZI`MB)Rm3jK?sYuWF{W{#8Ml=K}DgO7_ zuFBTY{I9Iea(Z}8*Lc^-$K!S+Oka=SW4oMMkNua+$3Q!uz*pXv4D*LQ*GJKZp+);$ zEnY_R$N20w@5|}PXyUSdPS^L%dP^8%>R-P;Dm2Jxzqi{|M69=)_UUfEPCt*E)V-nY zGg~N0wAa^x0l}WBudj>s^`32kkEfffbx;C*JU-Zo&-df$Z+zR0y*V7j_s`3cF!S&B zPyGGwJD{2`yH{G<@2A)2sIcel7y{q-%fmjU*?r8rr_B~bKY`BBiSeKwzptmu`w>pU zuiNvM7n#>ktLq-`ufJgmbrQFIc{RJxjEFO78$8YNS|KOj5q3^!`nR+Z07U3bNkl^M z@4)IvqA#3of7%niyJF4#O)-uy!8qUUcmMmIl2{KEzkgWq`UG1&pZhI#b^_n`55%3E z-If4ggq}Feo{#sJ-E-a#wp-obYA16Y(>;0MAMdLph{8iVsgFD5rV`>p_mE5&rMj`L~f{KN0G#L|%kT{E%+Y$fR{D9G~$zr}6fyN4c5{ITy_{ zFZm1pMa6S3j;R3}pYbv$*rxiX7DfH3tnQPMEZ$h`2YSpYV+$~bOM06U?Y8-|ftD=|6}ziWuM?@~Eq=BH!F8`UmyYvkr;iL*!Ll3L zjPF9<&zo^Z#C@Ii7uLXyD@av&o0096T|8@xkJuvv;e-(w9Zs?6-=-gZDfGI6*fqbw zb+@CJVfSv!*aigsZ|^*t%(ftRe7v{yw|zZc4hzWzeUJ4EXK;^V(NWxf6z$4))2PCR6c*4cr< z&a48!?%~|aw$&^gy`jw2FPYK9KirT2)TDMRXF&8kG!L&+N^5zR=&(biF>b>wJEyh#7DSQKGEY`!xKC)rHKwd+p_vDQa)xYe1@=Q`BfEI` z?wg#D@L}i{+#BKuxTud})ysUZvCB~z0V>EA9@Z@&7Drhj6)~4<-CR~d$I!#HvK{m4 zxHVYt%kHMi?|{E8j8iK6X@5EMZs!*Y*-?glSzAUb$TQo2?>}M)s(Axp$G?9{_f z8FR^LlOH+n+FfzSzryvr(FBUNjP^CA3s^c=`ztUfPkT(Y&y)7tbN}$L12Z}Y9{F$Nmx`Doh99$xw ze-zTms`RT`t9+D}OBNg1`0x}sPk-wYuWB`}OT}V9rBI|pDuN0ez41i=FL+~6SY6R6 z4)WCkA>)r?fGZB(V3IL7yIu=H)@}RkjwI_vU;KDQv3k_bR)*v56dqw1L`7^l-V(hC z&#(2#Ztn-X?2dk3fqEx#J-Pd9$Ii1=+dGsCgjRwYQOX88@eC61Vu=X)7Vb4wu}Q(lFne=;wSr)L6r#(}o4de!5HT%+;lsBJ3MNv7x+8C<)!wU9uJ`j3KD{^kmBw{>q z39rAehBoj{KWWd2rkG>W#Q>ZZ`XWxGqb?%6LSFYxOv1p-qujQuVvWsqAHLLh)PnAw z#T!w@)@pSPihWFNcX&;i?#%xtV+rDCfbs2Ti zWQH+-$2rKDvvorQU4%g=?26bg7s!ky<(ypGO&b^~gF^8AXMPi_j>Q?wbKlJtIc7G~ zF|!`VE@Rw|Oh(sF15t*@4UN~1-=?&Jz)fmFAh*sg1tS4|f zb*G=B+|Pl(*{A37HX}N$sc0IlSCv(3jcGs-*#;5Qmry`fhJu02nB%qfux)62ag=wqa1;5%r4A1g|*a<}NLwRk(jrZic2rOk^E^7(Oi|bU%#K7fW-U5$ z!wTleZu)Xyadhh;t{z^hxbuCc-EHMJIb)EYtS?7@SJLY3&}`J(F{cUJWFj%&aCuda zqwAQ=6E@U{Tvfp4cyr8&DW&2aNCGe~CO0Tdn#@RV=|nuXC0l6DU3J+e+b?h@+B3M0 zyB`kl`^-9g{hjNsBDaE-7z_0m&XRQy^d$wadAhtm+^u_yprFoK__rFmAZBs8O$BCD zokzGn$*$S;&5pvCk}0-sv%RqUyQ}`-6e+lrDzT0+46`TE9cjF+)c8xNjU7Ek04-+t z^56oCAVs1Vy*yRTGmO<{IE$i;nJ<$nss?HLs5M!=T&1N{-!wuD+lIXoK3x@KwuI_O zifS0ej2dvi^Z3vg7ZGGeBi1l6hh7&=Zuo6ruT3cBkjM0kQo@gQ7_gnqh3d!yUS-J>Gs@qSNfi3s&z4hwVy||_wLP~aH5JrF-PFX zzoa_Ezg6&hr88)Yj8WDBgCaXX9}X+qS}ZsQD-(m#G4{AAsI5S^=Z2w4Cvhv+FWn0p zf)<1gLQmC&qq6Z%6_eE=1LYVNgcZ{7vu*v%n78RPj9QN+i^XJ^D%xbb{5jL4J2|!Y zf+ji=q4?OI`8`nF^N@2&o=V?!0IH_04+xiGL^kqy)MXBN`ffYKvU5^1c6#w zT21v5=0vbQ8DFN|Bnq9nakZU&%c@@(bL(i}ji|n}?AXyVGH7=y?&Ik!E_kBex3XNc z%L|_g1lE_xoDa{49_S83dYoet7VorjF$mkS;^y5H?M;RNd@9K|E(v@=;sRFbX52Z2 zF{hs6U!}lynEi_k*IUTn+y5P&_a@SosunbozUJ^mv}Ks!uGmsKycr(@qjG1%qA&Sj z2}K!%NuG0TVD`B8q}MbFg^+`j{WVPK9t#$11@uEttf;R;n((rud_-`zOTia#O9zN>|w z{`QN9r3>=CqsEQV7N5dbg_Es%LJEA@+Hvd8ihaTk9;KT99(7h0B-L3==oOR6!9j^l zh?e}6DZhM7eE1xd6dUfCWl|wfT93t^ypY`ey}Ex~dhxF*lKPg6hayVW8)WwM_Mt31 z{f(98<@qRF$lC1%01ghX1MSLgp;5?TVW?AgM?4VW92V?%LSx-Lc3Nu3D4 zd@&x9>xnRvW4(E51W>l_yIH)AWweDiMveJ%-9s(KF4|RSH+-G?Q~FE4h*VBV5hNaS z;<(`moC+&>hsNI=s4*VB@h*8HxqVMO4VO+OCTl=;;}nDPZd3`9tf@t=Oi8c|f`^#PdV|*u zA4i6E4OV1ZUZl)vngp{4?uZSY2zF!yo{FsbpP4ZC0*LagbnXlz8p&I<$TDkKtMf@i znL6@^R&TO>kQXi=9DEA@MZ-N0M6U%8vq=$c5BXm>FLZj-f9l#p+FFJw;(;r|+vy+> z;$G*t*(u79+viiu%wurYP4%&2BcV*WDEKJNgZ0c9 zTDTeJ=!nE7SC$NuH5x3Y0i_sI`}?OSaHoVcpGfSmT=R2BEjQqa=D#zcfg07KKOq~; z75=Jb%-&yF0cTZ{rs~^G^9JdNIMC9g9AJKho2b*tB9@o1TaY9^$V#VC!`h?9%M3%= zF%rDLDqJz|^Cy3yz(7#~!cGT{j@C=3}RKn3z;6=hjni&dJvu0_Y*B1K^|2Sr4G zT1i%0XE)UzG!Rs==DzVKuUU2XjpvzOlkE;1U=yxaZTE9?IA#rkMt8Y z=BG!0jBc~@&R6~V)9_oqk7BPVWQ^RkQi<|boA2o2f-7LBRdY(?bFa>=@(a06cpmd?s^T%+Fc3%i=O&UDS>;7<^F;UfUDC{H48h0$dv<%KyN9 zJA#xlj1lnJ)`Z5% zBqQpfkaI$Yvtw^1cJajFfg7tmho!Az2}RfPuw!m?v)GO7PsCL;gmz)0R=c}I;#sLE zVFmo$mJBpN4?Gd`cOI`+PVUss7lZM7l1`tepy`7^=igyJ3oWthaupFmQv#flOss!i zO5iw2qMh31`6yx_tL1GdF}SwnWC%9Ddi|81?M}RwHAfbd!F@Ft6K&tbW0ejfusg>* zX`^79$a2qeL9(Z?r3{1DK?34HU(@-f7mk~+WprDS5p{~T<&D#NSA`G$ov{6$y759v zsGL&fP=;)|OM)vZYa(GKZ-ZzgAsU8jr=xBYX zntekt(iMIm8c`P|7w75K`DIN~%XYn68_B$*RIx+?`prqSS&|8OA`2r*R-Gfw@?|q8 zu#jE>cB;O}ELemIz87S5%{ekY;5GPDQ}N)rU&c?ur0^ok9-D zarWL2I)jC%k$?vsxKz=x2HM|;*cu}=#c{0>o?NdEAB=5)fwYkS#)yL>^VnR<&mEei z%c#cI6E>{kv}=_!p*yKW?$gdibf5o^=OJ#H>Tn;bceAj4Ms?fK@lW_tw_;%`)^E{} zzIIPqXFDQW*skOb39(skU2%h+I|Hx7sMjpDli)-0?fYoHBPI8S)-%Ly1>$GSh<5sI zq)yQbC`VZ4%z z$Y>MM$JQeh5q!2haLNMu?21E*G{LKe|6+ z%h9pAoSHMyQ+yuQO|XW6?cyrX9cSxMKE9y+$-0w7*M`V@2?tKTNt)4pu#Qg6L2qk8 zS2o6rBfGTu9BmF#u-Kg!OvE*gR5=-F8j}epoKmcR&%9eKjdEl0aJ%5~6IAO4oFKiL zVOr~IJ-AZ&M}I7V{(&@*-jy66!as350+k{09Wzh}E|4$mw*S)c1+*%eqx?1QfOTSc z3&~QmJ`vUMvv@7>B;I6kvi;|qJEb@F*;K$muFA}?oYqJxS=hO2cxccyY>r#-SlDUT zo3H$eJOK~|Pwmnatq2FF9x*1r!1hpf3IC-3A&|rx=m;CIT`EfyrlNE(|0Pvg3zZA5 ztF(hc{(3l&`T zqH0oEnnS+kOf}UBR6%p$x1&2Tf#_+}mNs1eIPRQtpPX_CT+Y4%&su~t)39sGGW|OIU63;u*3~s|~7(nld z6JUvYVLIP$G7a}{fY8@4zlxpEE^jv&@m=K43$KK!!OAnAeHdYT356y6Fc@}Q#*mB+ z)DRvFWczJqTA?AAEKZQLxMZCcUq)bqIac1ubU5Se4GoeWTXmo9JPp(N0sMe7f4-(6 zGt7_r&Gw9M(LK!5PpZhc?yxZ-jNYp=q+X}lVg)1GlygbNVQ!`C^Ap5Cm3kCzI7D{o z`>A*lfSX7`^9s(55T*8k-o^)oI5|kxn={RF9b{BzDToH6C0M=Qf|1(8jdika*qb-J zkIaknzi-;@T;sTAEEbX^Hm{S)!xEBoL8ZH7^>zc3}U$q$=9j z)W{>v3k-y6OmH`-?%27*m5c&j5lq*`Y3f%3PpfRr?v0B#B5txY$<_va1XL$ap%Uqf zdLNZhzF)Q*4u=wT!qnu{dnodm2N@6p@n)MS{5`@1DST$tHq6t-NaO8aP-rAL@2V>4 zWT>FcH+m97S~e>+H@k05v{4>U*oAoQXY&3JV&raCY`X4;m>QSTqL4j(z(=Nf!JMY( zvUPg^6c@&!i{p{v7)Ryu5*e&o)#5FV{CnXJzo7y8%_4qtm2NgF(M`2>s zDP@8Q&B;No@xtHG?C7@k_g6q=v*$x?!Qlh2s_UgHKe_tvgsa{KY(`#i>iLm7oyx#; zdsG1ZXXEw5NniMmS|ve`W(f4*TFtY|C1l^&c>hN7L1GhGEVj!I-#X~ zU|7sM=Vt8jxDFKVn} zBKQs3op|eFy*waxZA@HXMbf{2(!?sMsU+=W`=Aaq!W^aK^bKp2L(t4upo(=87_(&{ zJ0(9!=`#cZ7V}TlvRUgB;!yEDE(MRp5y97`TG*6zY%k;y+3Z5wf7@sC$`r10wxfVi zh9SJ)A)s3y`GnvksuV2kLV3eYIszbpcR-g&+mU;3Sc|A?K{g|SAkFp<@pySln_Q?a z)7!wNH_gSdc zl202+ha|Xo)Aknl8k@Q#02SYf16i@b_}lX_s-x8JkwrosxXtz%0}|ugU1o~FF}m2w z-sTc#!w;5U1^LQ~IZa$B^#_&Q62uIjiHeH(3Y3_P+0Npb#S4ytnQ zonxloA@p(3z4NG_74s;(^z*DQf0(wQAsE?x)g7eI0bWen@h4GuvnU4DBajL;havJghXU$1Vk-b4ZPVc#R znzIPoJ%k_Va8GszB>ZH|EnMaca6QJPSBkc{nW#uK@MZrJir_0()29PR3_3aEqU;&D2wMc5WD`s zl8pLTrok?W1Zs*yz##zVCoAhjUWpQoLwMG6{3V)iilb10EgK?)fQ*BsWCvz>P!?*V zf`LrZ18pwZiw_{Yg5x)4YIC?^KS#^49UCP}G`sYQm)$7jpet(sk~bZI&4BG0`~wi^ zHQ(5wTl@ZTgK1vZmgG_4EnDSGLk6QvWy1yi@vq*s(JY@@s1`C&-7?=E4RVBLnm#!| zuq2Btdq}OA)(;- zRODmz#nJ^B;DmPN3Zec*b;cZHa5in8RmJAW9nxIm*Q53;-kr5w#KY9RPzfi|IDvgc z0EF;Qz-qXAL1qsx4uxu6vH+LGB zW>?!QN}^{)ASL=p#6YH2nkN9WEVgo!4A=yma`FQaenIVxiQu^~;>L>Ps!LuUKgU?&EVKY=0NxaCMm%DA&FGU;5CTADU0glwz9}xR}TtM7H#fEy^04I5cZ$oZB}~ z*{N;V;h%=&PL^br_R#yd#8>VrRKOxd_AXB*Lqpv}=gT?Z-|!f#1XeyC1oKhc4| z^A_*zhuXT6Ij{vZEA>{llAz@0bu#ssD?D63rU3qX^Up$DW{RMSt{#054;`(>{8WwD z$FTQxR}Dbgto3Zls_0% zL7lQTmt0O^Gs$BsG{)L!hQVdI*yO)Qe?RG$Bi@F7=OW6OUCA+*Wy1oyQkZa+@`noD zLV1=EGRfOgc#3DuU#|4@VQQ_}V&u+!Nh4wi=o}v_K-pJ-s+$0=chgumh;Hz{68zy16{7e+0`UBp3XH|i? z40HA+JJkh`V_n6#9m{@8a}6iuIrVC#yVNGXhwR)n5Z1Y^y9lGw5v7+{p@*(*q+^7b z23(A%9wNNHdp*rOHK!Y z6r8V9b)iCu%csNRg9OLbU!9f_$>XFF#ism=d^RYcf{Veo6 z6i2Gxf#3&!T6~{Aume%8F2o16XAWYgx5KV5`+kS3NE@OWnKQ+_8K=q|As@RBzFr|~ zFkA1D-Vps+K~rgqTQ)G|>e_VFlx>@)j!M_MxkCeI^@wD98a(C2@!nc+nszUyGb=Gd zS1@dfRI7aT&K;P!8T4cj%2P}%Emi91haiXmz}IHYG9tO!q!DEbtuVkc!cwmUKdz{V z`D_+bhz4THZ6dR1H3@_-x~-Y1<$B6%8bk6tP3GBS(Kq{?nk9+nYZ~C5nPaTDhlSu> z7xcvW!tVo6*}0Qtu0#Vtg_LSmajOIQZzhiL)^2TY~Qc{yxL}mFP;Ankkd~ zr(#h-Lfu*N%+%P?t3^Ix3K5%J+c1RcUPynfifYy-80K#4W zL}j9$uWYt$M5r5;ucU+Co4OVMSZAX=M_Zt!auDo0^855N%hc17W-Z+sgpB*GYO}PC zS`H$gfTy8SnW?$&YxU;WH zgb!_#A_tPYQtkU=QN+%m^wz$A&8bZEztfR$i@VXs7@R8AA`5`vd4wN0sQ1`94n4DK zO=S`laICLaJX{eJsf;lyAjV={2QQPFe}LgM2dh(RU{z&4xCw_?88XP12lJyd)FE_~2bnag14Io-p=wSK>Pz}Yibut{qd>Tu zf3Lv8qXLUVNI$Xn`TVm+1mK(wy87HFKTK+y=u%Gn?on=z0zf=2n!l-7_4)+yXG^a$ zm2}apxW}Mba+FyvW!(P+|o^JI*+u9C*n0oBC>woY@g))Q$s#^g8v5V zqaIn}N`x{qPY4CDO7+FCLcd_07`l0bOw96ta7~d3^JNra{Ui0tGlu1s3ob&~^S04X zRnvcjM`=xWxBW2BaC^MOoYT#}HuZ$v=aXiPsxO)0l*HTxnB*p8l_Qe z&@|V`MLk_vwV4&iH%1mbkRjJ@BSo=2+;CWI5eNS^x?WGZD-QUziJMRs(72QZgSvJY z5ei^0+10U>3S`EHC;@Qz0=suNdF~P0eguKy(?%VEI>~RJDqDMf~TDCuT1ErpU zJuR56*gv+kNn`EmfX$mTy{W9hm`A5au((TohOz$l|_R@9A-okVF`Fb@Eam|Z3M zjTHu+SBkus>=M*p%=g+c$2#@IRRxYr1@?lxb&PX|S@yfKg#2-s*hV0U%%-i+qi-T# z8!`dPw?Ja5{IsnRev0-_uz~w*6_h#Vrjxs#g!*jPHfK0Gm^r$c^ELvyrE9)LQnoCa z)zk&}6>mCNtvm%VUOTMOy>;^`b?6U=-7pLPaszL!{B6+1{d*2GXGPu=+M`0nQ`G3a zy2gw%n<%d|Jf#g|ki5e{)|H=Wc%aZw)D>L2o)+uzD*OVbnSdf;Dvhoy(xwmmwrjmQ zaHrA3dGfFn5t@?%cHg=l$(EY&mflq}N65ah)1&7vkYpg`{H-cd)|geGsditv+Bzpq zqZEzcRtwTmxl((J1U^n7^&S{n%=Ft_2O%&_9Nf)pzCW?5fP8uG>k&y;hLlw2hRa`t zwN7cq%d}&Vwqj||q*rnaft$0>l-K@S;QqG2;jaFMKc-!g9{eFw<-M5A*Y!{8T$sCX zvDqJG!9UuHS>0d4cu2J$BX>`6LL5jtn3WxT-2j|Y6fPEGX)d^4?zu`@qpLyC+I!^v zPA?i}SPVCTY{U;&y(2=5SA9Raw471V08+x;mAwDo!J?$ySh-KCRAB&Z+cw3zE-cDG z$8aJA(fXF>f-IZt0Jnlx4T!qJZUF*q6+Gc_EA?+o0$!D=P8uGHjSFjVg3E}x!rW}K zQsBOa=WQre)y^0m^g`?KG#Vi;zA!WI*Sg(GM!&@! zqJULGR~+3R1|Xkz_2mB18AGH5hPkieuq`U;Bk@f0mb_v4WU z>=6y(oJAEc5av?;^;kTQ#?TmncA@%f#x=L*)@;;8yTXaG7nlkdPtacEg=@b7>Ol2K zu_g{S@@&y(-57fLj`4XTb*oHPK;eR=D?xV=dQVkR$_`gL@^;JrK`S)cla11)Fjbz0 z=Mk;`#GAO{>Zn!CO?s{NSBs5&`xQA~$lYn7sRBwc4=R`q*j-X~u;f>tGsO#Wf58mKa7RzYGUr&Rv`FTd9 zm!N}yW1Tp!Iryafsf+3Tse_zogd+Ugt_C@XW3vVDuC?n4mscuygovA|3?{C!ob)~G z36+-M9zxmz{sK?&F;xSPN#sxIOCQ^OraXj1Yjt7_>LUYUnZvHj0%*7g-F|#c-LD^A z`iF`vdu;z)O_F}%fDsYq<9L%$zsKjx{qA*qq$Mq=&hh<9tw+9jFMDm*12(Zea>o8& zX~LsBy#s|xC{$ql4Miudznma)uam===n9$(si{$9KV(O_7G4g33HPp8HjQ3@-Y#NJ zNm$4Km86THCa}Iton(hTNNl(wH>>c$*!H0`tq}-36Kz}@{yyVNemWSRFEcEWtIfnY4U6+DUiMG^tno(e&1Dom03L6Nci0s6xBGzh)B z1|b=AjDWf68;t@EY>1bW={;~gkd^HYMJl>bsfMV9f@X@W&w-YVk3FE zQRpPx(Tu@QL!C=G>DqB>tN-oJAlSP*3^%C=SywRRVj zP*YS!X!Cf@Xi5RZPyA)Yb>rbFr8WMskr8^YWWadO8&JLXIXX{J2~vbSQ6#G#*YWMU z-S%c{iBq?Yl&RT&6p*a74a}8AbUs*$+)vU5sN2dvgOQS(93aFo*`+4(1 zqxSs4^c!>zlKn;jC$i07XXsvTu3e_u(*7v&c$CA3S`8WL+%Yjh7RWM8<2*P>h&oMoGh-N3nOceK0Ha$&Op*c5U0V z`$7_)yDk|c>5xDP&p@4}&EINGfJBba&IR0Z1`a>XgPN-&!c0{YJi#Bp@zjNUdc!oA z+t=l0+pZ~vpp4X2Kmtei;L;C2h8Dnp*F_9SnT33`6kXcfc6Y8Lf@3Ipf!V@Xk3wlL zqy>(j%3k^l9bF)nCCi)^x;#P~Sbgch?pcXTp^^S31T@V?)0mmF7lZ=LRMQ<;pL3R? zzfavKnGoA@!I0Y?peQCC#F+m08-w${uldJnC9d%!=hjNB*@Cf*XC}!M>KkNgv1lid z#_iLFs}=;(v8e4HiXm%m+1g*p9IJC0Z%51>QYGE_)#gfN|njaZ}L)9SkSBMEII!&3{s$D zs~odxyF(u7jC&%+q8C+o7z9HX}>v}Ay9;c?oY|7@E!V1rlH78Tv64Y8 z=!o%Drj4JW`EX)IrdFpro;t~Pz#PrsV+)FIy@~j7tg=!m+CR-w+6pFujJoW+$)y>} z4bTl|dyHl@ereDFT#jNyjiLS10LFQTtwPKt_hf}k9h!LozeZa*a|6hM4Dk+06Wr)X zCP7}I`lfXee6B5a4#hGuBFD9CvUtpz>N)`u`^b(@1Q&V>C3U0NHkBi@bM7%-Y)f2r zxwCwlxwqX8IYw2HqqkcU@VKIG*aAzn#%H0p-r8hN0KRcKrpci!mjYCOE`IW zVGb6<{@^ZM)wFmP{sWAX<3i|+$qB}ZZp(;Ti-qS$rMqA&EeR$c<;U}Ht@o$3>Vmz_HyZpcJ1w1e(*lG z(1Sisd)ty4#_2{m+tHYkH!zJs#>t~Iepg!=L~Z^%U@rtiQZ>LjhfO(irTZs9z$KOz zQI{ObOyD&bWp0f>+Mq#SAV%C6!=X^qdwU1gdCog?&|tODUJu29V7WDW)CFnS#5)Ya zL=bQran1BTRGZ~U3Y#`o#IH^<6G+lW9it&lA7T_2RZC<4L`gHYy8}lSjVnzbYV@$l zB=C8VHeTkna$$;gKy^@^m(tIeUgJD4Fl_4pAPvvu1*0!jw!xz>y-9ZqaPP2#D$$tM zz5dW1c=JQJSE@0%#5>{=aL%22aFC>Z+>>>5Ljo*OCSnIaqW=Bq-L|fz7($ z0|6wJFG97*y~Ym)!sOCP1Eed;uClr{25=mPpo+`8+eeMFb%)-*6Qsx8*F0t`*#UXqBTw$4$a8HsA#)q03f*zyaiMN=}1NVkr|CHHG8Vzxa`6l4JrhG1}Q* zBppF)4GUjNe<;Uu?0iwvn~_T zR{yBdJ49d>dNQ@l!|TM3Z^S|K?_za!wN=1`rlU_80oqkfia-7&6o5QimQ|7`2|9zdCdvVa@tKu>Hy~scrdiH z;F?srvwMI?*iBa$JmK{PfpoimgkP=K`BmozMJFjghK;w&9Zj!L)N+CvaLeN&_DvK6 z?aIb?ATgZC?T#V_8c3g7xeYGtKI2{o24kqsQ9GX~OEUdZXicFICL+0_9N5Tcoi?%^ zt%-s=wZ7~Uau;1nF;_q)xlRPuXC~#T^wXhZO%WXS$IO%Q5xz%!PwGq>H{h_l^|10y~=tLys} zQYL%2A&LQ2Mv+6QyM8`4o#%fr%gYMqN+G2UYg8^GvU@vQE@THeHI@>V;dv%giN}bS z;kSGP`_I8-%@cMzfy(-ZncthZtm0(g|987X3Z1lMmy>jZT~6{1W;sbWI9!(eCzs1o zjLqe;RCn{ZoD!rS<+8NT%S&|HIbJ3?;(FNV>k3-}!)|=+OW_)Qb|*9}FD9Uum(!&JpV5JJw=NJ$DNJqt)X&0G%<}4XgE?3` zVj0COg2snTtaM|b6QG5Tw7Z5jGV2IsXe2Cm!fw~3;kE4nqG&3wm9Uuu^MW ztQ6*QitjDo2pui4!xbOtaW+EPO%L6w?7I+)!3h-juQ@LzBQ92k*`_1?Fj46AB4!IZ z^sjuTkPM^8?)C2d4QbV=u=f+oKh25UPMShR_?|>M72zw*-mlQ|&I@7pel5ZvoeCg1 zo5*oPj&y>sItJ+^{)JVI{+ys$4g#@8sKNd(^@rFFnk@zWup0~(V+fU?us7N^?5J)zP1fR7fnY_IrnHmL$WSRrhOZ|V=zIADK`BNXl1w9(STk8}l?3Z@AnQSUw{pRVEX_E;su>cUN+4^dfY6v({1kv|MGAaP(sL3_ zT_eiS?;d>}&~W5hl34F3-ZIOTs&_3{48t70%89AN3!@g?mSt_gsKwnptcsO0UWY-C zW>#N6R!)}p0-HyoteyhBqB*aOXKN;ckaa6R8}!!YFIXRWE9S7_1|#%T^0KIo5;tm# z4%+{rL-AZ2uPf}Axhe3&JU4g$Ap@e z7RvlO00ket5YCYZq$dH8Li-wBes(B#UI3Z^a8(Wfw(=B!Q4-}DfKVL)sHt18%1w&| z2NN3NIubxE?&j4eqoi95kI>QKjEEzSCPH{0nb2zUa`i6creQeb>hKcCRocfLxp=6w z&?ICIIi7KOg{; zy}0^Nx|H&)C6=t66`buj>kF(l^D4(SqCPl9C~}E3KS%hXEeFv}EMWDZ*<86`1OirB zqr>|$ShFZ9GFujmiWh{2227LK5g==gML{qa6w2X-nDGgG=vjMC-4#m!QmAZEb11)m!Bke zYs6M1?np(19I-0#c!00qBMeTf`~hOS*eM{h_XxJ?>OFO*+jcV%_{-jR1`a%R*@BSd zcnYl!o+hc$UNqe}rKPu~ZI0$2DRQejF}O-ponz5_R`(J`lEXQd{rs=x{v#))4Pk(& z=n|G7YOca@P=&}ANMI^NyTWBPe*$vYthIU_oHqqt^@ygHV5rc%K${f7bxPIeGy%&x zuj7)n0641y_9C+?$`ZUjy9`!?r_aA2n`G7jK2vi>)gpEL>RyUJzaeyF?~2ieRZ z9H|qV_L%|DS?;Ej<(9ik`sp?_YJM``ttZ0T9A!V|x`m3oNWeT4theeU>Fz@JJG2Q# zKQ;v3P$7ZyL~R$k#u>;k0Ny%`q(m}gN!P%tHgc*wfX|&*fi;(BMuq}ta`}!W2C!UD zq69R5uKee?nRh6fOIEoiR|1-?-17oNU4EKy8XwUPxL~ru+9$B6j_EDVErjH7x{Cb- z0wZ{zgxc1)1=RPzoS_-Yv@;zv#RHagjazU8c7}mX#U#nnz#Ea;P5_Nwq@Bei@@CzV z4JgqF*tGt1V&Ab177rR62T(|EZTv)jlGPkYc|;@Nf`SK+KVrG=iD1JfkuTN}GuQAN zAT-D4%>!Jc53Wc7L2b?vEiljy6+WQvDAwo$86Z5sIt=62Loyy{%tOS^As#E=m!|Im z*y5?{?MnyZ0iJ@LqB$EGoLtpB*9CHvbm`28U7~XJdG~55mG!B37$52Ylb~B@pk_lC z&XAqo5u`Wgc6lu#(`vr^BWiw**-d+NC9^LkX z+!SW8R!X=XjM9k24o1KnMfL`%)rkN3u-l{AF1qa?&m7Hm&F30N)3<}Azg_z$n-D-L zSdYy5Efo+3rg#VAt#vC|UmbrAEOAG&Ubt-_cHl`^YU9HRmPjnK2P~1;vGk~t0s2$I zg{Wdljex%Q!mSdor3?uUiQ?A?>lB1SVx5c+_}g!P`Qz{3LgkmY*!h8V`QLy1Jz`X^ z@yYN0@Vk-^bh(`IzsgEFQOBsT~bqzM=R+KRqc7pamUdkz4O3Qk1?nf-Ki1Na3!9R$M zSw2v%J{|#p*7hLCZuc8v8K`VPGjjB0G_P+OnEQNPmLZ-pYj0a$K)#QNI~vAl5;a@m zTX{iEf{sQF;PS0u1Rfu$tAY6^QE$qOIBGHw#qlJFx1nu40TAnVE4K7Ek|w*DNA1U; zKQ7W5%>u$hlK6uW_|FhtllQfT2j(%2>;vB^9})Pz-(kdXdJ(K_IgN5}`Ifo%0`C(N zm(JHSf>GoDx87qhDu;JGh?m@iMpn}sqOllK7M;)a`DFxH(`kwlc(?L%0`FGtXTkCP z(O`&wUcfi$cMEa`Vm6+HZ;DR~f`RW=?D(dndyncos*J$hCw$9u1K%O>7Na4_YRcx@ zCHS81=jTUH-((JEwcv*n2)iq47}5O3#WIDTsOK2^I9>f8|B(5LpQ^B&Z=G15{Hed_ z6!nbDX*1zcG?!8c`4Iskt=Me~iM5T?3)apejnE(0IL{po2@V(FU+SxJ(-BmIM$%vR zMQ>)2S09e|>$!4w^ggh3UtW>#s4}wTi!D+qM>j|v{j%a?MD>$rFs=l~c zx9k3nv%ZiW58Cji?E_%9Dj8(-NPX-SU!i6AB66flx1BbVBSmw;k5{&mTz4jb539?n z2csn=5wh(4S}sUQ5a1^ynokA%1U2A?Lst>9M`S24T9X!NUw+9zOv;bc2n$-Yqf)wL zxx;wfkv7WeKuoH>Am)zh$IEq(lch1LL0y;k^T5=LsxKfpTByE(EPu4BUK#nV6X zbb$3VPD_if?<0eC@LMUY;|1ri4n;Gcj0%T!E6-p}azDV3V6y^iqIU|$aSA8Gihurp zfB(P#{%R~(CY#TtXCp*{@I zJq^pz9S@oMo(uEk&Oy9^9NsC%1gp){DDZ~&$#Oe-hUDjiykl5(rhWuQu<8$}A;{~& z(f?d7uK)2b@gHLRi6QzQ`IS&nMDii#QvL>`@j((!VuD$yKZI5$BaC3VCh zD!!}zO5&T69oZUvgxvUDX%C3s3btf-MA*%_N!6F%$ra}{p+i!+0o-x@W#e};4a|Qh zwKyi*qb>ZI|4u3&mwM4Dg@)e=Po)1&Mqh7?C4b|KvmXWVO}bCVkAmgKCqdr)D3<@g zk7E1}`B4z9gdfFEC!3#$RvIi}nB>YCV2NN(f^h-IUWg@MA%g4BEjU()AeOY44A}3G zQCJ`*$pM8W2K^9uxx%Q_=ap{gm5fmN0du@B$1)i7(}I~9`e<`_I^LGkbu6{6K8^YH zU0x|Pj#+SGQp&^a_3c==T_#Z$04xxM5nTZATVM={*}(!q++^bRvwkakHp}G^VZxa+F>pWPs-Qn+cX_jl zkem`Hf6}q+V1PULonbF;65y_U(Wwwa%b#WRj~Dj9eMWLLc>c=~rQ?_T&Ca`m)4$4M z?NA7SbEz)p8#^xh?jzs-025a z_fl|@XQkk{V6=t4;)f(T>Z3}WV-E>ZH*CePB6oI2Ntb>=qBFWQftteK8JR%VRx?*$ zB6*e_RYzj(?L0)U{hlApn5$3l`6oJN`U&t4_H&i$kM2tmG&AqT-#m3*{7v$t_?zYW z-hS}+x2JoIF7-bZ`Qq|6|M4#&VS{-l{_THaA6V`<|1y&F6Omi`3ClhGgyb1Zf(yo< zh9Be{5|nN8Y`=nAI_g;J#p^2Zqq^VBq9pxfykFlNMi-B^?~i%Ixq1^058!>2 z2;PbOdy0c_Ci3rKB-%H!c|c18+)NC5m^TdeYG~0-(+XfOIXD zGH+m%^blQI-L4)_o@@8G<~j<7@5eg2qvkupmPb>yBtaoW!jiy_b19UpDqmUNex(44 z*uW5u*kR()KNYKGrBL*SCR){5U z%Me#GT=s_@b=|XIS+2g=$l`c3e*)+a)7O`CWIS)7zJbhXSgVwp-zh&Tu=aDMQfFf1 zo`fZ)%#=3;n%WC|YffTMhhZ^J{ox1vwcLJ0Q?lj;mDC*FG&(AQ#})*q96dI*Gg$%Ac_oEVP)sS6Jpsi^-dL zIZK*ExoVjwoeaj6Fw=c0T*pc>S<25S=>`gL`UWUpRQ}*lM3`pHjRc6-Suvcug(O%U zRgQD0k2tN|G8bcD4&plC1jLi5^+y-oQD|hl&)McvR%?bhL-fYl3-nJa7qp{Xx2N>(%vWyuBf%dQg z#3v2(-m(mdQPfR@yx<@sSR$O$h!(D>|C66FFuqQILGc17a{jk`{rfsH`5aDe9dXiG zE@aBo1LBjCH?D$}t|Bwj?F<>hv6-i;C@r0YnP$1z5O^CzZZ+Ll#Y}G|x6UoWkRiC0 zCkSSBN3f7vj^N;&5_cnLFP$hLIIsM{{yu0P%gYSGPv!QG*LLIo@TAei;;@J9Mg^3` z`2gv4a_cdqr)O|TPoJapKw`HiSpw-0WbuJ2GDx>mmO;9eTQx8s&2o7VK$x&YfC41(5=3wtFwC2-Uk(Fa<^s+k{a|x*r3O5 zjeZ(-c|5_3(P5)_QTFSneb=J7uv~r~4$1YUJU@`w0Yu`g3JVu>VpJhP`+SY$hr|~3 z_}tqkR)rndFAY(RvK}U`15k<*XHfg--(ODSWc7FD`I87&UWE02x*A1T8nmx(!K?r4 zTW~KmBxC*p-|ut%9*ft@WQ+V8&*n+fy1$ED6OMj}yon(RT8WW*N}leeWu8#-#1H-q z)p8A*eF-PA$G}*em^)n1Lu1J^r_BRBwP>Fi7?38f`z4|b;0KD>_kx=mnwt&}1OF9Bol?2e!`$*fJ)f?KjO8_Ci zEFhXn2=|tiLh@u=CHl)76hsr}^dv+R=aksrR<2ue*xy#1*k9qC%qN9@57D*W%$U^B zar--IB=)ybPLAKT;7~j>}GPVsczVWDLeB<-P_;zd0;$FG& zji*&-lcoxXgt8n^ZRaX8z9iScG>k9F`^J~ptS3R0i<;yCV0>G7Vtk1qu`m~DXQV$a zm}0UP=Bg48bA{w&{l{Q_r`zFhZ82Z4zajFI%SOreI9IMd2Wub3xnf}MKg6p^h^|mc zJ2O{VS|kxdx447?jdK9TtsFcI!yGn}JFi%Ue>lVIGk8kP=5>+!n`I zxiMa)&b~4Rj@peE#+ob64?^ zhVgh7Gt}{U;xkwBEFsq7@$)V-A=JdLFXa)M2vy>6aDkX(nB<9}?iH8`hKV_Q5{8*M zvpzPLb>@u3AV7I8UK~iCI?ln5DRT~wBP0okgEWp! zbJYyDa*th*dIH%;!Jh5PB*)$6~1Ivc#ZBu9|fFPe!m~? zIH?lJlulQVU)7J;If$pBIfyGCnfMa%I2MM4*^LLyI^~2+z6ls+2snmYd1iK5Zb4?c z{|bgTbAH#Bjk#Yf;ezDu4wKk}%q&h4E^xsZF!mn2Awkp?@?&T%U|T2W}U>jzs`&L*>bi?Q<1c>Kae$aq*7?SDWdyD^VTZ-;3m4UP<9gx=i>$Y|05U-Sqfn2 zQuPH%hMX8ZfBvC9Rl=Xuca#~!>1KKVg8DWbR5UBzzko5}{k#M-ZY&8?+ME!^k}l^n#au`d8b)#a#a(_UXkaFv#?OZprXhm~5Tk+(cXe;-2Or)+G zhAP2wX#XBK!>m1}-LIZ~iS}P^F>Z7n512~`;|82nOikAV$hQMPm zAg>Z4IdHCw=Q+ zE4SAb`_}_5=;RYriMQkkNz&giBXfYJ>;&@cDl4ug2nRRKQh6KcBN*I`)tZ46I7#~!Fsc8QscH%MMZDR82ep}?w7Ju!#drD z<1pYH8zYZ$^(FRRJkR#1L0yPrWXXQ0zJesxDn#h-oH&W~1vR_q@zs6N$+lc7noZPY z%wy#R`nH+oDVht~wA~5N*2)vKk-DwVeD|cu5iXe4XR%H%%NLIlZ!!>)q-9n}P`cu9 zOAo*T*2?E=%P}%WE@+2=21V$o>R3zFmpwRwlB#bOKGl~4KO{q2 zJgV)!c{bD={3dg)#{0XNz<*9q|ZW`nh$eei~;!}R8 zk9$a}4d>^zeM)^tUQh6OzM=b-JSiH~0Ay*()%^ivN$%-QBtLb}C%bLtbBgAYXs5}v zz-%k`azhe(xgm+Oa1bt-Qto6vfZ3VNV4S_G65BI$6m&+TB77gt zaN475HP&?9?&Rvda_<<3W(IuhJ}fNhOx!5=0Lw|{c9iC{8ydfDrd*2V3>w`70y8AG zBh^WqV1~QUc?c2u6W<~~d|)QZg>)SRW}@^-_Zabcf|*GHeSeHz8Fji#vNo~wB{^kh z0y2oH6Ss4}8f*T(K8&3p3(FTwN@n@ut*OHfh=l|Wi?J^ubBNI-C3cT$=Corr>>kVI zC1I(iPJ_*cQkLq??9Q!?v?n|^$(_e0ak5qmyJKb6(C_frA;BKN>|T^Tx#61x35X{_ zwpOoEJ3p~juTwkn!-Re7P*wonOj#x1i8}y96a#+ww;^3P;msk9zUVq-r%_^qAiGqgM)(eC3}Ak0gE>RFD%s7*PSdI0UcD zNXzG1-38kl-?1A>x5WS-lN8MH+HUFH38&zq1pA80B>Wr4+1dtZjqf>^RHL@Q!!z-L z=nexEpUVq`FEL$j9X#Mm%w$^POH49%WEl<1(j<=NW>V%x#UmFF3bk@?l0xby^>h#~ z7gEOYHjxJj-hzzK@5EAHi)3uKR4ZgWd|9h;G^Yg-%#>0I^Eh14Pz`O=E}()zR-@;q z0c?$t!4?R_5&hY6AH)iO4tp8Tl5LfOU2>MDDy3n~`f|)-Qu&XrO|Y0GPxeZz?_o{W zU)~@?_`bP(sAw+hy=TIa++*-0_DnbyCoy$P1^8U#K_su+0(QMjY$~yN9LF-2DW?Izc{k>rIEQuI5$Z5?G z3~r{YL^I!j@#$ts3!spok(vzg?WEKL^?>%l7aaX}!>N5&WD_Yl^RA=5w6c0+r(Vlyc@BnQ4>^oC9m;Fn-|aRwo3H$A&O zR}Yk4Q0&q6s6M&B{JxIOn9>-txvTMc;;z;gmN^sk4X7_6lizb!@0HJ@Burx z-qVl79v5YC!eQcqju{L($dlefMO&7@3I=uJnZY4JTvY5ltYA^Algb_@&mzCYp-(n<2Vb-xH-f4)EocH-nWcIKUZH6eG@Ghb@lSOx~&#|^-B=`L` ziJdJXamFy>f=;Y6BMX7=VzZO+_TYsHDPM(lwA6296a@&*@lj8u>r zSF1q`6)h%&k*dsyMj%6g)H{~IM6H(PpqLR|WNvr#kvZYGGktp4sCDj!;x$=Ajyf>g zj09VgnXJlO2`)d?$1kVu821=oq&6Dx<0>5sL6RI*@aDo1yQ39@*lbW0PTz&w%>LY| zgPFsZ*dGzYgCqXH;VbNqMTDT=fp4wNk*R<|thA6>#7W@PV`yaXP1%}=2O(Mx4-17i z15T2@-q3;uM!W;6qAtAExy zcmS3dp*~bbJY4CwxddS;B?zv6?aHcM?8AlAH+<67gSQZ>M`DOL*l^w0{o_ z?mEGPdbJBWZJ;^b6^EHw8Dy?K`-JlI1YGMt+$jvUCbO{h6}g2CNtWGFzsm5+WjXQ^ zu}5y@h})WhWw}KxNgpq6)))U^GsknMsQz9MgNJkG}_;Z@;`nyZPV$t{jsJ{qhCB z`@`?Bo<$hWXuBtj$+J)qdMT5JPV-I zd1r81XGsF3b}A5t;xGKFGu_#4BU*%?=l}WBU;qBc-yeQCKB3p!sJR4iGz9aE<+j~s zv6(&=C*i?zLF!yqfiKlnB6Ie7)=;vs3cM-SBr)f(f(&fo9kr2{;dVz98(lQSx9q6T zAVb)j|HS$feZZL(G4w^;Jd-WuU^ZjC@jTIbPRw%=%cH(vaXOI^T(?d;MRhVJ7K^JQ zO|zL*EEHEI$6d+0Z~nBpV7jn3wddXWf)lzyB%e;$OQPV}rZz2vsZ%TFqVpUd=I3w^ ztcsHQs!z+~XVq7oFZ&`>WSKTBH=6N>$`=R-K({gxs5olFK-z(i#oAg70ByCKqY%f+ zGXPZDndvsRTrk2=6Zu|N$=#9|tHkjc_p)t4)i|Q76NBXKBg{7PNE!{ye`q{v3$=sZy5X zek(gMYA)`Q=2yX8qCy3&*<-QgwusnsS<40EPSHB!&JyiJ@M@M=ZcCL6$ue8ZS&Z?T zlzL_ieQ`Z(nG}F= zLjxeszswK-Nb*~!al>)Hm4FyESFK(fP_|CAte#lC2lXdKe?*}E^x(RMM0fD8dLglF zrz*i|H~gO8R0-ltzMV)}h`LT*;l2%sN8sS+LPRJ-3<##`GX&39v>CbL(2~;;e!@|$ zJ0KW@ia~JtJhg$Ll0dM}Nz^Zr-w+noc8-R`29mrn`(gvPQVPd?8za{J8rEG3Axp#y zHjpFKW&*KM5+VNcxWB8Rw88Qxpw=_rra!EIa_5sxJWM_X)tpK+I%D$UoZwcis^qGNF_L zHsuyzGI_=vutX{)fMvO|VtoG1i5;*kP7EwA=s2lW z;!bLnxRV-^K|%heJVyB*xColM@^+GSP3#`sB7ocd6{nK!_cu4O&=XDT(B#d3sGnzEYJE;eaD6u!fH%j;y1L^qmUVN{Bk=p$1lsxrJ(y?`v!S4L3AKK!SPFnnefv;Kaz5SU!jK_ zzfps67rO|19K@0O4Y0?Z25GNn9S!5cqlms>S{4d_VvQZbaZ6{UMc}+J^e;tvgmo_M zs4sz6cq7O2W&XGPeBRGPHvC4VPG)~6k*zwgzw?`MIw@pse`$)`Bc>V7>~E`owRU}s zkQMxHrmGGub#W8}^Xy1s>l?)41i;dH%_w)=el)=P!U5puyp{ksN>VJnr`}8ifbZOT z%|Z^NsOvwRc6W5@J0oU>18z>5_4#hP@rwJP5r_Iv2G=@3KQ62e9_OK&y`8kYNB-ye ziOt-$6LQul^f#W*vj$P&F0j6vTaq6;-FuIR%|utxoOf{AsR(dgt8&>fkd@Lsdu!_2s-4%z>wtr%m zfwEOHYrxs!*yp3`{e2I|)Xr?^sP`kWbLehlZ1UvWU<{(QP4srT*)pX06ji#?7NMYRt6 zU9=Y2BTLU?U(=|?S_0#hAImIh$gsxS>K`v5DfXXlX)s@9$iB)v4~x4krgk&Kb!Zuj zv%`>Dp^2fyc4?jAVsRFLt?Z2i60m%S#LlaoB|yYZYrocnu(p*((zHdG*HxnHjDA7r z8gac{_eiNjZvdV~xSeZ6mzICkaX9Yk_%hrrY?k3JUR8>_4$&nOCqn`5Xkr^5N^rNC zs5-RFLV`QuB^-B5d+-~1aip6kM|+nGrrZF_;ta{K*V?UF2Nfs@?&5}t5V)&skQQ42 zcdLZe>Au4eo;V@)icD{mZdsaCt{%&}<8B&<NRX4naDG+3v*;-K^STO< z!O1Fbss!~j*4%uOt)=E&OS3*SK)K-nFbi;wgXu52$fe>AJ3O-Y^T>4j5)YX*H=Uo; zcif>>4n4JmB5<|@WmZq|+T6O26~g`nS|HA6CJK?N{2=_D8n z;zU(|u}Oj!PL`rRiuw?Hl%k%i|7?mnFcu>-jMWh*QJv7SLq+Ry8{|>F8i!$3kFn!VtiW*@k!&b>oD$ z9SWV1Is!Tk$w@dGQ$4m^eF?j?shvocHlDGL%pg{Yr3_-VBFDB4^_^u_ER=NTz!Tg@ z*k&H(NIXh{Fp}Thig6%beKfj#x>82)--9qvoz_r!kFa8s#3OrE;0UWkiUVwoNV#2h zD44iiZ-@PW8k@Tlf#Wx>Y)(%=CJtxEp))r`2zAki4U2&p!0LcAfCb5ttdqe$WdI8$ z0~pP3A7Gn_j^jGfL1NHVJU)10L}gfZC5yceUC|#=q|VdJDoJW2LI<^|JQ|p=xSI?U zt7Hgve$30;Nw|_MvAherost*$CAi z(9PYJ-H>_tVQAg8@*JqGe1~oTfY~*^1N+oni9w@R4w^OGkf3f0Z>l~oyd^6L2U?#H z%|7if`-3mM9r`0ioFzz9Q9^&zH#)}Nd8nW3OR$z2fHBQfuN%|xxS0VdnoB=!4MxDd zYvqnJA~+mpkBWD2_a4rI8A);0sa}C`4AQ`QzXNEM0ZD+iMtK|&liTB5%JcPrt}>d~ z`fj@K$~F~Q40{|^h)ph*Vbsg=+{02%$`@g6Ku;C1kn!V(Z{KLh*hbVD3soJX@CIVXXOk9kSP1W4qeW z5FQhWN42gtJe4@AfyeSyrn+}t7Jg5gqb5{qlVGitJJ!~jh%&5Y+0MMdIf%{=j-_>y z3=R1*9XdTr)XxIeCY`%N0$57}@<7@e0ddD6m8X*Ma=at|2#ce@9_DV1fGA0eSEL>w zmW(}%Ce6U6#So<_(JG64N!aa6RD|5>HLe)o06NCqDy5)6g77@wwZc z`j{z zsRxW|;M}r=MoAYoI9&{R8HeIUqcfbE-gnb|`?lw}-tZ3?ibvrU=VSfcI*@&;l63v5 zPpRt{&q5zVo1^&yMdP*Cz%z7bZB{5d;ETmh7xCTwfUlxIE>oUXUL^^LqAtKQDnkcc zoe|$5+T)ZM-OP=muHSPSc1Sb3qsu7VYeW|;*qW<1n>jxINOqYY>MKC=e$UU1QKMSX zf~+Arm85d>v+IlSrH#oKd6;i9-I_xCo#kk_$nWxp=r=WnvUF}H2L>)w&^inNmJXnh zw~fVfogI+dYXZ`*MZXh!2+5%57{cmJI##j%x=IcQebcaeHS}&I`Y7q*bvW(z1NzpW zcN}=Aq6Mme9s~2mbAB_9)kWk`on-2CV)^8m);9#K3KRs4 z0P8wdfL6jvAFZ2Nfq{x3^w1!HmAZX7Sl8Qqk@g|8u9X+??LoH+0yixH&%hdm1P46^ z5@4OnAbgcxqXiHp11*4&WO4S+OSMNpasrg@{z)O!HG;3iNPX0jZ3f!n;VaU z=Fzr(hbUXX91{rCHv)`TK?D?&ejmk3I{fG1^rt`n@~{8(;L{t-0;?bcL-41g{%4GvvWmvsi-rBDr4Z-xsB3_TtafG1h->928=3WG72nJs48$MoExlai#_^L}T{56c${ZYBbIF26 zb)h;sr0Rt`unSz5@1Ljvqji3rCHZ>Pic(s3qw`mk>sY_T! zccphBf-Sfm*mi*jQ1^gL*rNwKiXbj%Tz5w;xWjIG7+;O!ArtO2-d^p1q`%6~%M0?Y z4pTc?dHfs6#PGH@^jTk;jKlYD_DHDGCG)?#_1Vn{c$76Zk%lkIyePqy#t^%8vd z%)zX*g)AX}eQ*$iNMJ`NN#mS-yRb2f8IzGu}eJeGj2oQ=Ts0z0+(byLXoR?w#aaPx7>xEh(A{TcZ5vGo<#A zHRtDl&SG!U&e{oFalvS6HKz2k+<98|Z0|4Q)b%} z90hIfrW>z#l?+x*PySd3mNjXQSAaA4l7LbOwPyMYPsz}@nrz`to4Jyr*(~rHV+3a8 z0$H|z8oI!;o>-hL@#EN+C0?5b0X*g5<_i+`1Q(3xQqC0^iYf|lsyJnRAxUhhj|;KX z>(U`K_&@9nBGO z9vE8BA~XVLR!?<=6)pE)K|9i}(1#A`ueh@0aPj!w*YP>pgA&WoK8-ujj=D`ld(?Ay zY7Eb_4d?6lPFkc`_Ou{H)1!g*b*usH?FB1nR~~9T#&lvfLf#2X`#Sl=>K0>Sagy7G z3-TCK2&^|c03Fo~KOQ4GSLf2ae_^76~8g^jP9{=_qw?wKUu z%dBXS7`9SaCHYBxlMC!CfLpnPyl~jcpJSCyn^~ozIoo@1eEezw>~&U�jva+Qi+0 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�#w&{$5%O@(h21#vOl&L|Vbs*=id&0!K6t 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 literal 0 HcmV?d00001 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..3172968 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP + +from flask import Flask + +from app.config import Config +from app.extensions import db, migrate +from app import models +from app.routes.api import api_bp +from app.routes.web import web_bp + + +def _format_decimal(value: object, digits: int = 4) -> str: + if value is None: + return "-" + + try: + decimal_value = Decimal(str(value)) + except (InvalidOperation, ValueError, TypeError): + return str(value) + + quantized = decimal_value.quantize(Decimal(f"1.{'0' * digits}"), rounding=ROUND_HALF_UP) + normalized = format(quantized, "f").rstrip("0").rstrip(".") + return normalized or "0" + + +def create_app(config_object: type[Config] = Config) -> Flask: + app = Flask(__name__, instance_relative_config=True) + app.config.from_object(config_object) + + db.init_app(app) + migrate.init_app(app, db) + + app.register_blueprint(web_bp) + app.register_blueprint(api_bp) + app.jinja_env.filters["fmt_decimal"] = _format_decimal + + return app diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..d6c98aa --- /dev/null +++ b/app/config.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import os +from pathlib import Path +from urllib.parse import quote_plus + +from dotenv import load_dotenv + + +BASE_DIR = Path(__file__).resolve().parent.parent +DEFAULT_SQLITE_PATH = BASE_DIR / "instance" / "ksp.sqlite3" + +load_dotenv(BASE_DIR / ".env") + + +def _build_database_uri() -> str: + host = os.getenv("PGHOST") + port = os.getenv("PGPORT") + user = os.getenv("PGUSER") + password = os.getenv("PGPASSWORD") + database = os.getenv("PGDATABASE") + + if all([host, port, user, password, database]): + return ( + "postgresql+psycopg://" + f"{quote_plus(user)}:{quote_plus(password)}@{host}:{port}/{quote_plus(database)}" + ) + + DEFAULT_SQLITE_PATH.parent.mkdir(parents=True, exist_ok=True) + return f"sqlite:///{DEFAULT_SQLITE_PATH.as_posix()}" + + +class Config: + APP_ENV = os.getenv("APP_ENV", "development") + APP_PORT = int(os.getenv("APP_PORT", "8000")) + APP_SECRET_KEY = os.getenv("APP_SECRET_KEY", "development-only-secret-key") + PROJECT_TITLE = os.getenv("PROJECT_TITLE", "KSP Data Hangar") + WORKBOOK_PATH = os.getenv("WORKBOOK_PATH", "KSP Engine Tweak Chart.xlsx") + SQLALCHEMY_DATABASE_URI = _build_database_uri() + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ECHO = os.getenv("SQLALCHEMY_ECHO", "false").lower() == "true" + SECRET_KEY = APP_SECRET_KEY diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..314cc74 --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,6 @@ +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy + + +db = SQLAlchemy() +migrate = Migrate() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..69a8961 --- /dev/null +++ b/app/models.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, Uuid +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.extensions import db + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False + ) + + +class EngineFamily(TimestampMixin, db.Model): + __tablename__ = "engine_families" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + engine_name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + part_name: Mapped[str | None] = mapped_column(String(255)) + cycle: Mapped[str | None] = mapped_column(String(120)) + size_m: Mapped[float | None] = mapped_column(Numeric(8, 3)) + entry_cost: Mapped[int | None] = mapped_column(Integer) + + variants: Mapped[list["EngineVariant"]] = relationship( + back_populates="family", + cascade="all, delete-orphan", + lazy="selectin", + order_by="EngineVariant.source_sheet_row", + ) + + +class EngineVariant(TimestampMixin, db.Model): + __tablename__ = "engine_variants" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + family_id: Mapped[uuid.UUID] = mapped_column( + Uuid, ForeignKey("engine_families.id", ondelete="CASCADE"), nullable=False, index=True + ) + fuel_type: Mapped[str] = mapped_column(String(120), nullable=False) + work_env: Mapped[str | None] = mapped_column(String(32)) + sl_isp: Mapped[float | None] = mapped_column(Numeric(10, 3)) + vac_isp: Mapped[float | None] = mapped_column(Numeric(10, 3)) + min_thrust_kn: Mapped[float | None] = mapped_column(Numeric(12, 3)) + max_thrust_kn: Mapped[float | None] = mapped_column(Numeric(12, 3)) + mass_t: Mapped[float | None] = mapped_column(Numeric(10, 4)) + twr: Mapped[float | None] = mapped_column(Numeric(12, 4)) + throttle_ratio: Mapped[float | None] = mapped_column(Numeric(8, 4)) + tvc_deg: Mapped[float | None] = mapped_column(Numeric(8, 3)) + price_kd: Mapped[int | None] = mapped_column(Integer) + ignitions: Mapped[int | None] = mapped_column(Integer) + has_unlimited_ignitions: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + mod_source: Mapped[str | None] = mapped_column(String(120)) + engine_note: Mapped[str | None] = mapped_column(Text) + config_name: Mapped[str | None] = mapped_column(String(255), index=True) + tech_required: Mapped[str | None] = mapped_column(String(120)) + config_note: Mapped[str | None] = mapped_column(Text) + source_sheet_row: Mapped[int | None] = mapped_column(Integer) + + family: Mapped[EngineFamily] = relationship(back_populates="variants") + + +class CommunicationPart(TimestampMixin, db.Model): + __tablename__ = "communication_parts" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + part_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + display_name: Mapped[str | None] = mapped_column(String(255)) + mass_t: Mapped[float | None] = mapped_column(Numeric(10, 4)) + is_active: Mapped[bool | None] = mapped_column(Boolean) + is_deployable: Mapped[bool | None] = mapped_column(Boolean) + antenna_type: Mapped[str | None] = mapped_column(String(120)) + deployed_diameter_m: Mapped[float | None] = mapped_column(Numeric(10, 3)) + range_raw: Mapped[float | None] = mapped_column(Numeric(24, 3)) + range_km: Mapped[float | None] = mapped_column(Numeric(24, 3)) + range_au: Mapped[float | None] = mapped_column(Numeric(18, 6)) + range_light_year: Mapped[float | None] = mapped_column(Numeric(18, 6)) + angle_deg: Mapped[float | None] = mapped_column(Numeric(10, 3)) + speed: Mapped[float | None] = mapped_column(Numeric(10, 3)) + idle_power_watt: Mapped[float | None] = mapped_column(Numeric(12, 4)) + idle_power_text: Mapped[str | None] = mapped_column(String(64)) + transmitting_power_watt: Mapped[float | None] = mapped_column(Numeric(12, 4)) + transmitting_power_text: Mapped[str | None] = mapped_column(String(64)) + source: Mapped[str | None] = mapped_column(String(120)) + entry_cost: Mapped[int | None] = mapped_column(Integer) + cost: Mapped[int | None] = mapped_column(Integer) + description: Mapped[str | None] = mapped_column(Text) + rescale_factor: Mapped[float | None] = mapped_column(Numeric(8, 3)) + tweakscale: Mapped[str | None] = mapped_column(String(64)) + is_feeder: Mapped[bool | None] = mapped_column(Boolean) + tech_required: Mapped[str | None] = mapped_column(String(120)) + note: Mapped[str | None] = mapped_column(Text) + + +class TankSpec(TimestampMixin, db.Model): + __tablename__ = "tank_specs" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + tank_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + fuel_type: Mapped[str | None] = mapped_column(String(120)) + dry_mass_t: Mapped[float | None] = mapped_column(Numeric(10, 4)) + fuel_mass_t: Mapped[float | None] = mapped_column(Numeric(10, 4)) + wet_mass_t: Mapped[float | None] = mapped_column(Numeric(10, 4)) + tank_volume_l: Mapped[float | None] = mapped_column(Numeric(12, 3)) + mass_ratio: Mapped[float | None] = mapped_column(Numeric(12, 4)) + kiloliters_per_ton: Mapped[float | None] = mapped_column(Numeric(12, 4)) + vehicle_name: Mapped[str | None] = mapped_column(String(120)) + source: Mapped[str | None] = mapped_column(String(120)) + note: Mapped[str | None] = mapped_column(Text) + + +class VehicleCost(TimestampMixin, db.Model): + __tablename__ = "vehicle_costs" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + vehicle_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + launch_price: Mapped[int | None] = mapped_column(Integer) + source: Mapped[str | None] = mapped_column(String(120)) + + +class Asset(TimestampMixin, db.Model): + __tablename__ = "assets" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) + asset_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + is_retired: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + program: Mapped[str | None] = mapped_column(String(255)) + home_region: Mapped[str | None] = mapped_column(String(120)) + note: Mapped[str | None] = mapped_column(Text) + + log_entries: Mapped[list["AssetLogEntry"]] = relationship( + back_populates="asset", + cascade="all, delete-orphan", + lazy="selectin", + order_by="AssetLogEntry.start_at", + ) + + +class AssetLogEntry(TimestampMixin, db.Model): + __tablename__ = "asset_log_entries" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + asset_id: Mapped[uuid.UUID] = mapped_column( + Uuid, ForeignKey("assets.id", ondelete="CASCADE"), nullable=False, index=True + ) + entry_kind: Mapped[str] = mapped_column(String(16), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + state_label: Mapped[str | None] = mapped_column(String(120)) + mission_label: Mapped[str | None] = mapped_column(String(255)) + location: Mapped[str | None] = mapped_column(String(255)) + start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + summary: Mapped[str | None] = mapped_column(Text) + note: Mapped[str | None] = mapped_column(Text) + + asset: Mapped[Asset] = relationship(back_populates="log_entries") + state_nodes: Mapped[list["AssetStateNode"]] = relationship( + back_populates="entry", + cascade="all, delete-orphan", + lazy="selectin", + order_by="AssetStateNode.at_time", + ) + + +class AssetStateNode(TimestampMixin, db.Model): + __tablename__ = "asset_state_nodes" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + entry_id: Mapped[uuid.UUID] = mapped_column( + Uuid, ForeignKey("asset_log_entries.id", ondelete="CASCADE"), nullable=False, index=True + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + detail: Mapped[str | None] = mapped_column(Text) + at_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + + entry: Mapped[AssetLogEntry] = relationship(back_populates="state_nodes") diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..dec3d8e --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1 @@ +"""Route blueprints for the KSP application.""" diff --git a/app/routes/api.py b/app/routes/api.py new file mode 100644 index 0000000..d949117 --- /dev/null +++ b/app/routes/api.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from flask import Blueprint, jsonify, request + +from app.services.fuel_conversion import convert_value, list_fuel_factors + + +api_bp = Blueprint("api", __name__, url_prefix="/api/v1") + + +@api_bp.get("/health") +def health() -> tuple[str, int] | tuple[dict[str, str], int] | object: + return jsonify({"status": "ok", "service": "ksp-data-hangar"}) + + +@api_bp.get("/fuel-converter/fuels") +def fuel_factors() -> object: + return jsonify({"fuels": list_fuel_factors()}) + + +@api_bp.route("/fuel-converter/convert", methods=["GET", "POST"]) +def convert_fuel() -> object: + payload = request.get_json(silent=True) or request.args + mode = payload.get("mode", "") + value = payload.get("value") + + try: + result = convert_value(mode=mode, raw_value=value) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + return jsonify(result) diff --git a/app/routes/web.py b/app/routes/web.py new file mode 100644 index 0000000..f9da422 --- /dev/null +++ b/app/routes/web.py @@ -0,0 +1,3768 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from difflib import SequenceMatcher +from math import ceil +from pathlib import Path +import re +from typing import Any + +from flask import Blueprint, abort, flash, has_request_context, redirect, render_template, request, session, url_for +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import selectinload + +from app.extensions import db +from app.models import Asset, AssetLogEntry, AssetStateNode, CommunicationPart, EngineFamily, EngineVariant, TankSpec, VehicleCost +from app.services.fuel_conversion import list_fuel_factors +from app.services.log_book_importer import import_log_book_data + + +web_bp = Blueprint("web", __name__) + +DEFAULT_PAGE_SIZE = 30 +CUSTOM_OPTION_VALUE = "__custom__" +LOG_BOOK_PATH = Path(__file__).resolve().parents[2] / "log_book.xlsx" +SIMULATION_TIME_SESSION_KEY = "simulation_time" + +ENGINE_SORT_OPTIONS = [ + {"value": "engine_name_asc", "label": "名称 A-Z"}, + {"value": "engine_name_desc", "label": "名称 Z-A"}, + {"value": "mass_t_asc", "label": "重量从低到高"}, + {"value": "mass_t_desc", "label": "重量从高到低"}, + {"value": "vac_thrust_asc", "label": "真空推力从低到高"}, + {"value": "vac_thrust_desc", "label": "真空推力从高到低"}, + {"value": "sl_thrust_asc", "label": "海平面推力从低到高"}, + {"value": "sl_thrust_desc", "label": "海平面推力从高到低"}, + {"value": "vac_isp_asc", "label": "真空比冲从低到高"}, + {"value": "vac_isp_desc", "label": "真空比冲从高到低"}, + {"value": "sl_isp_asc", "label": "海平面比冲从低到高"}, + {"value": "sl_isp_desc", "label": "海平面比冲从高到低"}, +] + +ENGINE_SORT_COLUMNS = [ + {"key": "engine_name", "label": "Engine"}, + {"key": "mass_t", "label": "Mass"}, + {"key": "sl_thrust", "label": "SL Thrust"}, + {"key": "vac_thrust", "label": "Vac Thrust"}, + {"key": "sl_isp", "label": "SL ISP"}, + {"key": "vac_isp", "label": "Vac ISP"}, +] + +COMM_SORT_OPTIONS = [ + {"value": "part_name", "label": "名称 A-Z"}, + {"value": "range_desc", "label": "通信距离从高到低"}, + {"value": "mass_desc", "label": "质量从高到低"}, +] + +TANK_SORT_OPTIONS = [ + {"value": "tank_name", "label": "名称 A-Z"}, + {"value": "volume_desc", "label": "容积从高到低"}, + {"value": "ratio_desc", "label": "质量比从高到低"}, +] + +VEHICLE_SORT_OPTIONS = [ + {"value": "vehicle_name", "label": "名称 A-Z"}, + {"value": "launch_price_desc", "label": "发射成本从高到低"}, + {"value": "launch_price_asc", "label": "发射成本从低到高"}, +] + +ASSET_SORT_OPTIONS = [ + {"value": "name_asc", "label": "名称 A-Z"}, + {"value": "asset_type_asc", "label": "类型 A-Z"}, + {"value": "updated_desc", "label": "最近更新优先"}, +] + +MISSION_PREVIEW_TIME = "2060-03-12 09:00" + +MISSION_PREVIEW_METRICS = [ + {"label": "Tracked Assets", "value": "14"}, + {"label": "Active Missions", "value": "6"}, + {"label": "Upcoming Events (30d)", "value": "9"}, +] + +MISSION_PREVIEW_FILTERS = { + "simulation_time": MISSION_PREVIEW_TIME, + "asset_type": "All Asset Types", + "location": "Earth System", + "record_scope": "Confirmed + Planned", +} + +MISSION_BOARD_RECORD_SCOPE_OPTIONS = [ + {"value": "all", "label": "全部资产"}, + {"value": "active", "label": "仅活动中"}, + {"value": "active_planned", "label": "活动中 + 计划中"}, + {"value": "recorded", "label": "仅已记录"}, +] + +TIMELINE_SCALE_OPTIONS = [ + {"value": "day", "label": "按日"}, + {"value": "month", "label": "按月"}, + {"value": "year", "label": "按年"}, +] + +MISSION_STATUS_GROUPS = [ + { + "title": "In Transit", + "accent": "warning", + "items": [ + { + "name": "XH-02 Taibai", + "asset_type": "Exploration Mothership", + "location": "Earth to Mars Transfer", + "mission": "Support Mars One Campus", + "state_window": "2060-02-26 to 2060-03-29", + "next_event": "Mars Arrival · 2060-03-29", + "summary": "31-day transfer, planned delta-v margin 105 km/s.", + "record_status": "Confirmed", + }, + { + "name": "Amalthea Crew Vehicle 4", + "asset_type": "Shuttle", + "location": "LKO to Lingxiao Station", + "mission": "Expedition 16 Crew Rotation", + "state_window": "2060-03-28 to 2060-03-30", + "next_event": "Docking Window · 2060-03-30", + "summary": "10 crew manifest uploaded, rendezvous burn pending.", + "record_status": "Planned", + }, + ], + }, + { + "title": "Docked / On Station", + "accent": "primary", + "items": [ + { + "name": "Lingxiao Station", + "asset_type": "Station", + "location": "Low Kerbin Orbit", + "mission": "Continuous habitation and logistics hub", + "state_window": "2059-07-23 to present", + "next_event": "Expedition 16 Crew Arrival · 2060-03-30", + "summary": "Expedition 15 is onboard, crew handover prep in progress.", + "record_status": "Active", + }, + { + "name": "Tianhe Cargo Pod 7", + "asset_type": "Cargo Shuttle", + "location": "Lingxiao Forward Berth", + "mission": "Dry cargo replenishment", + "state_window": "2060-03-01 to 2060-03-18", + "next_event": "Undock · 2060-03-18", + "summary": "Food, station spares, and EVA consumables unloaded.", + "record_status": "Confirmed", + }, + ], + }, + { + "title": "At Port / Surface Ops", + "accent": "neutral", + "items": [ + { + "name": "XH-03 Zhuque", + "asset_type": "Shuttle", + "location": "Star Port", + "mission": "Refit for Jupiter relay deployment", + "state_window": "2060-01-08 to 2060-04-12", + "next_event": "Rollout Review · 2060-04-12", + "summary": "Main engine refurbishment at bay 4, payload integration queued.", + "record_status": "Confirmed", + }, + { + "name": "Mars One Campus Alpha", + "asset_type": "Surface Outpost", + "location": "Mars Surface", + "mission": "Habitat expansion and ISRU staging", + "state_window": "2059-10-20 to present", + "next_event": "Taibai Supply Window · 2060-03-29", + "summary": "Surface crew awaiting structural truss shipment and greenhouse kit.", + "record_status": "Active", + }, + ], + }, +] + +MISSION_LOCATION_GROUPS = [ + { + "title": "Earth System", + "subtitle": "Ports, orbital stations, and nearby transfers", + "items": [ + { + "name": "Lingxiao Station", + "asset_type": "Station", + "state": "Docked / On Station", + "mission": "Crew rotation and logistics hub", + "location": "Low Kerbin Orbit", + "next_event": "Crew Arrival · 2060-03-30", + }, + { + "name": "XH-03 Zhuque", + "asset_type": "Shuttle", + "state": "At Port", + "mission": "Refit for Jupiter relay deployment", + "location": "Star Port", + "next_event": "Rollout Review · 2060-04-12", + }, + { + "name": "Amalthea Crew Vehicle 4", + "asset_type": "Shuttle", + "state": "In Transit", + "mission": "Expedition 16 Crew Rotation", + "location": "LKO Transfer Corridor", + "next_event": "Docking Window · 2060-03-30", + }, + ], + }, + { + "title": "Mars System", + "subtitle": "Orbital assets and surface support network", + "items": [ + { + "name": "Mars One Campus Alpha", + "asset_type": "Surface Outpost", + "state": "Surface Ops", + "mission": "Habitat expansion and ISRU staging", + "location": "Mars Surface", + "next_event": "Taibai Supply Window · 2060-03-29", + }, + { + "name": "XH-02 Taibai", + "asset_type": "Exploration Mothership", + "state": "In Transit", + "mission": "Support Mars One Campus", + "location": "Mars Transfer Lane", + "next_event": "Mars Arrival · 2060-03-29", + }, + ], + }, +] + +MISSION_UPCOMING_EVENTS = [ + {"when": "2060-03-18", "title": "Tianhe Cargo Pod 7 undock from Lingxiao Station", "scope": "Lingxiao Station"}, + {"when": "2060-03-29", "title": "XH-02 Taibai arrives at Mars", "scope": "Mars System"}, + {"when": "2060-03-30", "title": "Expedition 16 crew arrives via Amalthea Crew Vehicle", "scope": "Lingxiao Station"}, + {"when": "2060-04-02", "title": "Expedition 15 crew departs Lingxiao Station", "scope": "Lingxiao Station"}, +] + +MISSION_LOG_HEADER = { + "name": "XH-02 Taibai", + "asset_type": "Exploration Mothership", + "program": "Mars Expedition Program", + "current_state": "In Transit", + "current_location": "Earth to Mars Transfer", + "current_mission": "Support Mars One Campus", + "state_window": "2060-02-26 06:00 to 2060-03-29 18:00", + "next_event": "Mars Arrival · 2060-03-29 18:00", +} + +MISSION_LOG_ENTRIES = [ + { + "mode": "event", + "timestamp": "2058-03-15 10:20", + "title": "First module delivered to Star Port", + "location": "Star Port", + "record_status": "Completed", + "summary": "Core transfer spine and habitat ring unloaded for final assembly.", + }, + { + "mode": "event", + "timestamp": "2059-08-06 09:00", + "title": "Commissioned", + "location": "Star Port", + "record_status": "Completed", + "summary": "Flight certification signed for Mars transport duties.", + }, + { + "mode": "state", + "timestamp": "2059-08-08 04:10", + "end_timestamp": "2059-10-17 13:40", + "title": "Outbound transfer carrying Mars Expedition 3", + "location": "Earth to Mars Transfer", + "record_status": "Completed", + "summary": "Departure from Earth with full crew and descent stack attached.", + }, + { + "mode": "event", + "timestamp": "2059-10-17 13:40", + "title": "Mars Arrival", + "location": "Mars Orbit", + "record_status": "Completed", + "summary": "Insertion burn nominal. Crew descent staging began next day.", + }, + { + "mode": "state", + "timestamp": "2059-10-18 08:00", + "end_timestamp": "2059-10-19 06:10", + "title": "Mars orbit standby and crew pickup window", + "location": "Mars Orbit", + "record_status": "Completed", + "summary": "Received Mars Expedition 1 return crew after descent vehicle separation.", + }, + { + "mode": "state", + "timestamp": "2059-10-19 06:10", + "end_timestamp": "2059-12-25 22:30", + "title": "Return transfer from Mars to Earth", + "location": "Mars to Earth Transfer", + "record_status": "Completed", + "summary": "Return trajectory with Expedition 1 crew aboard.", + }, + { + "mode": "state", + "timestamp": "2060-02-26 06:00", + "end_timestamp": "2060-03-29 18:00", + "title": "Support mission transfer to Mars One Campus", + "location": "Earth to Mars Transfer", + "record_status": "Active", + "summary": "Carrying structural truss and surface greenhouse kit; 105 km/s, 31-day transition.", + }, + { + "mode": "event", + "timestamp": "2060-03-29 18:00", + "title": "Mars Arrival", + "location": "Mars Orbit", + "record_status": "Planned", + "summary": "Planned capture burn and cargo handoff to surface fleet.", + }, +] + +MISSION_TIMELINE_ROWS = [ + { + "name": "XH-02 Taibai", + "asset_type": "Exploration Mothership", + "segments": [ + {"label": "Transit to Mars", "start": 6, "span": 22, "tone": "warning"}, + {"label": "Mars Orbit", "start": 29, "span": 6, "tone": "primary"}, + {"label": "Return Transit", "start": 38, "span": 21, "tone": "neutral"}, + {"label": "Port Stay", "start": 61, "span": 8, "tone": "soft"}, + {"label": "Support Transit", "start": 73, "span": 22, "tone": "warning current"}, + ], + "events": [ + {"label": "Commissioned", "offset": 1}, + {"label": "Mars Arrival", "offset": 28}, + {"label": "Earth Arrival", "offset": 59}, + {"label": "Next Mars Arrival", "offset": 95}, + ], + }, + { + "name": "Lingxiao Station", + "asset_type": "Station", + "segments": [ + {"label": "Continuous Ops", "start": 0, "span": 96, "tone": "primary current"}, + ], + "events": [ + {"label": "Crew 9 Arrival", "offset": 10}, + {"label": "Expedition 15 Arrival", "offset": 62}, + {"label": "Expedition 16 Arrival", "offset": 88}, + ], + }, + { + "name": "Mars One Campus Alpha", + "asset_type": "Surface Outpost", + "segments": [ + {"label": "Surface Expansion", "start": 34, "span": 62, "tone": "neutral current"}, + ], + "events": [ + {"label": "First Crew Descent", "offset": 37}, + {"label": "Taibai Supply Window", "offset": 95}, + ], + }, +] + +MISSION_TIMELINE_SCALE = [ + "2059-08", + "2059-09", + "2059-10", + "2059-11", + "2059-12", + "2060-01", + "2060-02", + "2060-03", +] + + +def _mission_preview_context() -> dict[str, object]: + return { + "simulation_time": MISSION_PREVIEW_TIME, + "metrics": MISSION_PREVIEW_METRICS, + "filters": MISSION_PREVIEW_FILTERS, + "upcoming_events": MISSION_UPCOMING_EVENTS, + } + +FAMILY_FORM_FIELDS = [ + {"name": "engine_name", "label": "Engine", "kind": "text", "required": True}, + {"name": "part_name", "label": "Part Name", "kind": "text", "required": False}, + {"name": "cycle", "label": "Cycle", "kind": "combo", "required": False}, + {"name": "size_m", "label": "Size (m)", "kind": "decimal", "required": False}, + {"name": "entry_cost", "label": "Entry Cost", "kind": "integer", "required": False}, +] + +VARIANT_FORM_FIELDS = [ + {"name": "fuel_type", "label": "Fuel Type", "kind": "combo", "required": True}, + {"name": "config_name", "label": "Config Name", "kind": "text", "required": False}, + {"name": "work_env", "label": "Work Env", "kind": "text", "required": False}, + {"name": "sl_isp", "label": "SL ISP", "kind": "decimal", "required": False}, + {"name": "vac_isp", "label": "Vac ISP", "kind": "decimal", "required": False}, + {"name": "min_thrust_kn", "label": "Min Thrust (kN)", "kind": "decimal", "required": False}, + {"name": "max_thrust_kn", "label": "Max Thrust (kN)", "kind": "decimal", "required": False}, + {"name": "mass_t", "label": "Mass (t)", "kind": "decimal", "required": False}, + {"name": "twr", "label": "TWR", "kind": "decimal", "required": False}, + {"name": "throttle_ratio", "label": "Throttle Ratio", "kind": "decimal", "required": False}, + {"name": "tvc_deg", "label": "TVC (deg)", "kind": "decimal", "required": False}, + {"name": "price_kd", "label": "Price (KD)", "kind": "integer", "required": False}, + {"name": "ignitions", "label": "Ignitions", "kind": "integer", "required": False}, + { + "name": "has_unlimited_ignitions", + "label": "Unlimited Ignitions", + "kind": "checkbox", + "required": False, + }, + {"name": "mod_source", "label": "Mod Source", "kind": "combo", "required": False}, + {"name": "tech_required", "label": "Tech Required", "kind": "text", "required": False}, + {"name": "engine_note", "label": "Engine Note", "kind": "textarea", "required": False}, + {"name": "config_note", "label": "Config Note", "kind": "textarea", "required": False}, +] + +VARIANT_CREATE_FIELDS = [ + {"name": "fuel_type", "label": "Fuel Type", "kind": "combo", "required": True}, + {"name": "config_name", "label": "Config Name", "kind": "text", "required": False}, + {"name": "work_env", "label": "Work Env", "kind": "text", "required": False}, + {"name": "sl_isp", "label": "SL ISP", "kind": "decimal", "required": False}, + {"name": "vac_isp", "label": "Vac ISP", "kind": "decimal", "required": False}, + {"name": "max_thrust_kn", "label": "Max Thrust (kN)", "kind": "decimal", "required": False}, + {"name": "mod_source", "label": "Mod Source", "kind": "combo", "required": False}, +] + +COMMUNICATION_FIELDS = [ + {"name": "part_name", "label": "Part", "kind": "text", "required": True}, + {"name": "display_name", "label": "Part Showname", "kind": "text", "required": False}, + {"name": "antenna_type", "label": "Type", "kind": "text", "required": False}, + {"name": "source", "label": "Source", "kind": "text", "required": False}, + {"name": "mass_t", "label": "Mass (t)", "kind": "decimal", "required": False}, + {"name": "range_km", "label": "Range km", "kind": "decimal", "required": False}, + {"name": "angle_deg", "label": "Angle", "kind": "decimal", "required": False}, + {"name": "idle_power_watt", "label": "Idle Power (W)", "kind": "decimal", "required": False}, + { + "name": "transmitting_power_watt", + "label": "Transmitting Power (W)", + "kind": "decimal", + "required": False, + }, + {"name": "entry_cost", "label": "Entry Cost", "kind": "integer", "required": False}, + {"name": "cost", "label": "Cost", "kind": "integer", "required": False}, + {"name": "is_active", "label": "Is Active", "kind": "checkbox", "required": False}, + { + "name": "is_deployable", + "label": "Is Deployable", + "kind": "checkbox", + "required": False, + }, + {"name": "is_feeder", "label": "Is Feeder", "kind": "checkbox", "required": False}, + {"name": "description", "label": "Description", "kind": "textarea", "required": False}, + {"name": "note", "label": "Note", "kind": "textarea", "required": False}, +] + +TANK_FIELDS = [ + {"name": "tank_name", "label": "Tank", "kind": "text", "required": True}, + {"name": "fuel_type", "label": "Fuel Type", "kind": "text", "required": False}, + {"name": "vehicle_name", "label": "Vehicle", "kind": "text", "required": False}, + {"name": "source", "label": "Source", "kind": "text", "required": False}, + {"name": "dry_mass_t", "label": "Dry Mass", "kind": "decimal", "required": False}, + {"name": "fuel_mass_t", "label": "Fuel Mass", "kind": "decimal", "required": False}, + {"name": "wet_mass_t", "label": "Wet Mass", "kind": "decimal", "required": False}, + {"name": "tank_volume_l", "label": "Tank Volume (L)", "kind": "decimal", "required": False}, + {"name": "mass_ratio", "label": "Mass Ratio", "kind": "decimal", "required": False}, + { + "name": "kiloliters_per_ton", + "label": "KL per ton", + "kind": "decimal", + "required": False, + }, + {"name": "note", "label": "Note", "kind": "textarea", "required": False}, +] + +VEHICLE_FIELDS = [ + {"name": "vehicle_name", "label": "Vehicle", "kind": "text", "required": True}, + {"name": "launch_price", "label": "Launch Price", "kind": "integer", "required": False}, + {"name": "source", "label": "Source", "kind": "text", "required": False}, +] + +ASSET_FIELDS = [ + {"name": "name", "label": "Asset", "kind": "text", "required": True}, + {"name": "asset_type", "label": "Asset Type", "kind": "combo", "required": True}, + {"name": "is_retired", "label": "Retired", "kind": "checkbox", "required": False}, + {"name": "program", "label": "Program", "kind": "text", "required": False}, + {"name": "home_region", "label": "Home Region", "kind": "text", "required": False}, + {"name": "note", "label": "Note", "kind": "textarea", "required": False}, +] + +ASSET_TYPE_BASE_OPTIONS = ( + "Shuttle", + "Station", + "Surface Outpost", + "Exploration Mothership", +) + +ASSET_ENTRY_KIND_OPTIONS = [ + {"value": "event", "label": "Event Point"}, + {"value": "state", "label": "State Interval"}, +] + +PROJECT_METRICS = [ + {"label": "Engine rows", "value": "478"}, + {"label": "Communication rows", "value": "50"}, + {"label": "Tank rows", "value": "27"}, + {"label": "Vehicle cost rows", "value": "10"}, +] + +MODULES = [ + { + "title": "Engine catalog", + "status": "live", + "description": "Core engine families and variants with filters, pagination, create/delete, and editable variants.", + "endpoint": "web.engine_list", + }, + { + "title": "Communication parts", + "status": "live", + "description": "Paginated communication part list with source/type filters and edit forms.", + "endpoint": "web.communication_list", + }, + { + "title": "Tank specs", + "status": "live", + "description": "Paginated tank list with fuel/source filters and edit forms.", + "endpoint": "web.tank_list", + }, + { + "title": "Vehicle costs", + "status": "live", + "description": "Paginated vehicle cost list with sorting and edit forms.", + "endpoint": "web.vehicle_list", + }, + { + "title": "Mission assets", + "status": "beta", + "description": "Asset CRUD with real asset log entries and simulation-time-derived current state.", + "endpoint": "web.asset_list", + }, + { + "title": "Fuel converter", + "status": "live", + "description": "A working liters-to-tons and tons-to-liters converter based on the workbook coefficients.", + "endpoint": "web.fuel_converter", + }, +] + + +@dataclass(slots=True) +class EngineCatalogRow: + family: EngineFamily + config_count: int + fuel_types: tuple[str, ...] + mod_sources: tuple[str, ...] + mass_t: Decimal | None + twr: Decimal | None + vac_thrust_kn: Decimal | None + sl_thrust_kn: Decimal | None + vac_isp: Decimal | None + sl_isp: Decimal | None + + +def _engine_family_statement() -> object: + return select(EngineFamily).options(selectinload(EngineFamily.variants)) + + +def _load_model_or_404(model: type[Any], object_id: object) -> Any: + instance = db.session.get(model, object_id) + if instance is None: + abort(404) + return instance + + +def _load_engine_family_or_404(family_id: object) -> EngineFamily: + statement = _engine_family_statement().where(EngineFamily.id == family_id) + family = db.session.execute(statement).scalar_one_or_none() + if family is None: + abort(404) + return family + + +def _normalized_text(raw_value: str | None) -> str | None: + if raw_value is None: + return None + cleaned = raw_value.strip() + return cleaned or None + + +def _parse_decimal(raw_value: str | None, label: str) -> Decimal | None: + cleaned = _normalized_text(raw_value) + if cleaned is None: + return None + + try: + return Decimal(cleaned) + except InvalidOperation as exc: + raise ValueError(f"{label} 必须是数值") from exc + + +def _parse_integer(raw_value: str | None, label: str) -> int | None: + cleaned = _normalized_text(raw_value) + if cleaned is None: + return None + + try: + decimal_value = Decimal(cleaned) + except InvalidOperation as exc: + raise ValueError(f"{label} 必须是整数") from exc + + if decimal_value != decimal_value.to_integral_value(): + raise ValueError(f"{label} 必须是整数") + + return int(decimal_value) + + +def _as_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _parse_datetime(raw_value: str | None, label: str) -> datetime | None: + cleaned = _normalized_text(raw_value) + if cleaned is None: + return None + + try: + parsed = datetime.fromisoformat(cleaned.replace(" ", "T")) + except ValueError as exc: + normalized = ( + cleaned.replace("年", "/") + .replace("月", "/") + .replace("日", " ") + .replace("T", " ") + ) + match = re.fullmatch(r"(\d{4})[/-](\d{1,2})[/-](\d{1,2})\s+(\d{1,2}):(\d{1,2})", " ".join(normalized.split())) + if match is None: + raise ValueError(f"{label} 必须是合法时间") from exc + try: + parsed = datetime( + int(match.group(1)), + int(match.group(2)), + int(match.group(3)), + int(match.group(4)), + int(match.group(5)), + ) + except ValueError as inner_exc: + raise ValueError(f"{label} 必须是合法时间") from inner_exc + + return _as_utc(parsed) + + +def _default_simulation_time() -> datetime: + return datetime(2060, 3, 12, 9, 0, tzinfo=timezone.utc) + + +def _remember_simulation_time(value: datetime) -> None: + if not has_request_context(): + return + session[SIMULATION_TIME_SESSION_KEY] = _format_datetime_input(value) + + +def _saved_simulation_time_value() -> str | None: + if not has_request_context(): + return None + saved_value = session.get(SIMULATION_TIME_SESSION_KEY) + return saved_value if isinstance(saved_value, str) else None + + +def _resolve_simulation_time(raw_value: str | None) -> datetime: + try: + parsed_value = _parse_datetime(raw_value, "Simulation Time") + except ValueError: + parsed_value = None + + if parsed_value is not None: + _remember_simulation_time(parsed_value) + return parsed_value + + saved_value = _saved_simulation_time_value() + if saved_value is not None: + try: + saved_datetime = _parse_datetime(saved_value, "Simulation Time") + except ValueError: + saved_datetime = None + if saved_datetime is not None: + return saved_datetime + + default_value = _default_simulation_time() + _remember_simulation_time(default_value) + return default_value + + +def _format_datetime_display(value: datetime | None) -> str: + normalized = _as_utc(value) + if normalized is None: + return "-" + return normalized.strftime("%Y-%m-%d %H:%M") + + +def _format_datetime_input(value: datetime | None) -> str: + normalized = _as_utc(value) + if normalized is None: + return "" + return normalized.strftime("%Y-%m-%dT%H:%M") + + +def _datetime_input_parts(value: datetime | None) -> dict[str, str]: + normalized = _as_utc(value) + if normalized is None: + return {"year": "", "month": "", "day": "", "hour": "", "minute": ""} + return { + "year": normalized.strftime("%Y"), + "month": normalized.strftime("%m"), + "day": normalized.strftime("%d"), + "hour": normalized.strftime("%H"), + "minute": normalized.strftime("%M"), + } + + +def _parse_datetime_form_value( + form: object, + field_name: str, + label: str, + *, + required: bool = False, +) -> datetime | None: + part_names = ("year", "month", "day", "hour", "minute") + part_values = {part: _normalized_text(form.get(f"{field_name}_{part}")) for part in part_names} + + if any(value is not None for value in part_values.values()): + if any(value is None for value in part_values.values()): + raise ValueError(f"{label} 需要完整填写年月日时分") + try: + return datetime( + int(part_values["year"]), + int(part_values["month"]), + int(part_values["day"]), + int(part_values["hour"]), + int(part_values["minute"]), + tzinfo=timezone.utc, + ) + except ValueError as exc: + raise ValueError(f"{label} 必须是合法时间") from exc + + parsed_value = _parse_datetime(form.get(field_name), label) + if required and parsed_value is None: + raise ValueError(f"{label} 不能为空") + return parsed_value + + +def _apply_fields( + target: object, + form: object, + field_specs: list[dict[str, object]], + prefix: str = "", +) -> None: + for field_spec in field_specs: + field_name = field_spec["name"] + full_name = f"{prefix}{field_name}" + field_kind = field_spec["kind"] + label = field_spec["label"] + required = bool(field_spec.get("required")) + + if field_kind == "checkbox": + value = full_name in form + elif field_kind == "combo": + selected_value = _normalized_text(form.get(full_name)) + custom_value = _normalized_text(form.get(f"{full_name}__custom")) + if custom_value is not None: + value = custom_value + elif selected_value == CUSTOM_OPTION_VALUE: + value = None + else: + value = selected_value + elif field_kind == "decimal": + value = _parse_decimal(form.get(full_name), label) + elif field_kind == "integer": + value = _parse_integer(form.get(full_name), label) + else: + value = _normalized_text(form.get(full_name)) + + if required and value in (None, ""): + raise ValueError(f"{label} 不能为空") + + setattr(target, field_name, value) + + +def _collect_form_values(entity: object, field_specs: list[dict[str, object]]) -> dict[str, object]: + return {field["name"]: getattr(entity, field["name"], None) for field in field_specs} + + +def _load_asset_or_404(asset_id: object) -> Asset: + statement = select(Asset).options(selectinload(Asset.log_entries).selectinload(AssetLogEntry.state_nodes)).where(Asset.id == asset_id) + asset = db.session.execute(statement).scalar_one_or_none() + if asset is None: + abort(404) + return asset + + +def _load_asset_entry_or_404(asset_id: object, entry_id: object) -> AssetLogEntry: + entry = db.session.get(AssetLogEntry, entry_id) + if entry is None or entry.asset_id != asset_id: + abort(404) + return entry + + +def _apply_asset_entry_fields(target: AssetLogEntry, form: object) -> None: + entry_kind = _normalized_text(form.get("entry_kind")) or "event" + allowed_kinds = {option["value"] for option in ASSET_ENTRY_KIND_OPTIONS} + if entry_kind not in allowed_kinds: + raise ValueError("Entry Type 无效") + + title = _normalized_text(form.get("title")) + if title is None: + raise ValueError("Title 不能为空") + + start_at = _parse_datetime_form_value(form, "start_at", "Start At", required=True) + if start_at is None: + raise ValueError("Start At 不能为空") + + end_at = _parse_datetime_form_value(form, "end_at", "End At") + if entry_kind == "event": + end_at = None + elif end_at is not None and end_at <= start_at: + raise ValueError("End At 必须晚于 Start At") + + state_node_rows = [] + if entry_kind == "state": + state_node_rows = _parse_state_node_rows(form, start_at, end_at) + + target.entry_kind = entry_kind + target.title = title + target.state_label = _normalize_multi_value_text(form.get("state_label")) + target.mission_label = _normalize_multi_value_text(form.get("mission_label")) + target.location = _normalized_text(form.get("location")) + target.start_at = start_at + target.end_at = end_at + target.summary = _normalized_text(form.get("summary")) + target.note = _normalized_text(form.get("note")) + + if target.entry_kind == "state" and target.state_label is None: + target.state_label = title + + _sync_state_nodes(target, state_node_rows if entry_kind == "state" else []) + + +def _asset_entry_form_values(entry: AssetLogEntry) -> dict[str, object]: + return { + "entry_kind": entry.entry_kind or "event", + "title": entry.title or "", + "state_label": entry.state_label or "", + "mission_label": entry.mission_label or "", + "location": entry.location or "", + "start_at": _format_datetime_input(entry.start_at), + "start_at_parts": _datetime_input_parts(entry.start_at), + "end_at": _format_datetime_input(entry.end_at), + "end_at_parts": _datetime_input_parts(entry.end_at), + "summary": entry.summary or "", + "note": entry.note or "", + "state_nodes": [ + { + "id": str(node.id) if node.id is not None else "", + "title": node.title or "", + "detail": node.detail or "", + "at": _format_datetime_input(node.at_time), + } + for node in sorted(entry.state_nodes, key=lambda item: _as_utc(item.at_time) or _default_simulation_time()) + ], + } + + +def _parse_state_node_rows( + form: object, + start_at: datetime, + end_at: datetime | None, +) -> list[dict[str, object]]: + node_ids = form.getlist("state_node_id") + node_titles = form.getlist("state_node_title") + node_details = form.getlist("state_node_detail") + node_times = form.getlist("state_node_at") + + if not node_ids and not node_titles and not node_details and not node_times: + return [] + + if ( + len(node_ids) != len(node_titles) + or len(node_titles) != len(node_details) + or len(node_details) != len(node_times) + ): + raise ValueError("State Node 数据不完整") + + state_nodes = [] + for index, (raw_id, raw_title, raw_detail, raw_at_time) in enumerate( + zip(node_ids, node_titles, node_details, node_times), + start=1, + ): + node_id = _normalized_text(raw_id) + title = _normalized_text(raw_title) + detail = _normalized_text(raw_detail) + at_time = _parse_datetime(raw_at_time, f"State Node {index} At") + + if title is None and detail is None and at_time is None: + continue + if title is None or at_time is None: + raise ValueError(f"State Node {index} 需要同时填写 Title 和 At") + if at_time < start_at: + raise ValueError(f"State Node {index} 时间不能早于 Start At") + if end_at is not None and at_time > end_at: + raise ValueError(f"State Node {index} 时间不能晚于 End At") + + state_nodes.append( + { + "id": node_id, + "title": title, + "detail": detail, + "at_time": at_time, + } + ) + + return state_nodes + + +def _sync_state_nodes(target: AssetLogEntry, state_node_rows: list[dict[str, object]]) -> None: + existing_nodes = {str(node.id): node for node in target.state_nodes if node.id is not None} + next_nodes = [] + + for row in state_node_rows: + raw_node_id = row["id"] + if isinstance(raw_node_id, str) and raw_node_id: + node = existing_nodes.pop(raw_node_id, None) + if node is None: + raise ValueError("State Node 无效") + else: + node = AssetStateNode() + + node.title = str(row["title"]) + node.detail = row["detail"] + node.at_time = row["at_time"] + next_nodes.append(node) + + target.state_nodes = next_nodes + + +def _normalize_variant_ignitions(variant: EngineVariant) -> None: + if variant.has_unlimited_ignitions: + variant.ignitions = None + return + + if variant.ignitions is None: + variant.has_unlimited_ignitions = True + + +def _calculate_variant_twr(max_thrust: Decimal | None, mass: Decimal | None) -> Decimal | None: + if max_thrust is None or mass in (None, Decimal("0")): + return None + return max_thrust / Decimal("9.80665") / mass + + +def _calculate_variant_throttle_ratio( + min_thrust: Decimal | None, max_thrust: Decimal | None +) -> Decimal | None: + if min_thrust is None or max_thrust in (None, Decimal("0")): + return None + return min_thrust / max_thrust + + +def _refresh_variant_derived_fields(variant: EngineVariant) -> None: + variant.twr = _calculate_variant_twr(variant.max_thrust_kn, variant.mass_t) + variant.throttle_ratio = _calculate_variant_throttle_ratio( + variant.min_thrust_kn, + variant.max_thrust_kn, + ) + + +def _distinct_variant_values(family: EngineFamily, attribute_name: str) -> tuple[str, ...]: + values = { + getattr(variant, attribute_name) + for variant in family.variants + if getattr(variant, attribute_name) not in (None, "") + } + return tuple(sorted(values, key=str.casefold)) + + +def _distinct_text_values(values: list[str | None]) -> tuple[str, ...]: + cleaned_values = {value.strip() for value in values if value and value.strip()} + return tuple(sorted(cleaned_values, key=str.casefold)) + + +def _engine_form_options() -> dict[str, tuple[str, ...]]: + cycle_values = db.session.execute(select(EngineFamily.cycle)).scalars().all() + fuel_values = db.session.execute(select(EngineVariant.fuel_type)).scalars().all() + mod_values = db.session.execute(select(EngineVariant.mod_source)).scalars().all() + return { + "cycle": _distinct_text_values(cycle_values), + "fuel_type": _distinct_text_values(fuel_values), + "mod_source": _distinct_text_values(mod_values), + } + + +def _asset_form_options() -> dict[str, tuple[str, ...]]: + asset_type_values = db.session.execute(select(Asset.asset_type)).scalars().all() + return { + "asset_type": _distinct_text_values([*ASSET_TYPE_BASE_OPTIONS, *asset_type_values]), + } + + +def _max_variant_decimal(family: EngineFamily, attribute_name: str) -> Decimal | None: + values = [ + getattr(variant, attribute_name) + for variant in family.variants + if getattr(variant, attribute_name) is not None + ] + return max(values) if values else None + + +def _estimate_sl_thrust_kn(variant: EngineVariant) -> Decimal | None: + if variant.max_thrust_kn is None: + return None + if variant.sl_isp is None or variant.vac_isp in (None, Decimal("0")): + return None + return variant.max_thrust_kn * variant.sl_isp / variant.vac_isp + + +def _build_catalog_row(family: EngineFamily) -> EngineCatalogRow: + sl_thrust_values = [ + value for value in (_estimate_sl_thrust_kn(variant) for variant in family.variants) if value is not None + ] + return EngineCatalogRow( + family=family, + config_count=len(family.variants), + fuel_types=_distinct_variant_values(family, "fuel_type"), + mod_sources=_distinct_variant_values(family, "mod_source"), + mass_t=_max_variant_decimal(family, "mass_t"), + twr=_max_variant_decimal(family, "twr"), + vac_thrust_kn=_max_variant_decimal(family, "max_thrust_kn"), + sl_thrust_kn=max(sl_thrust_values) if sl_thrust_values else None, + vac_isp=_max_variant_decimal(family, "vac_isp"), + sl_isp=_max_variant_decimal(family, "sl_isp"), + ) + + +def _build_catalog_rows() -> list[EngineCatalogRow]: + families = db.session.execute( + _engine_family_statement().order_by(EngineFamily.engine_name.asc()) + ).scalars().unique().all() + return [_build_catalog_row(family) for family in families] + + +def _matches_catalog_search(row: EngineCatalogRow, search_term: str) -> bool: + normalized = search_term.casefold() + search_targets = [ + row.family.engine_name, + row.family.part_name or "", + row.family.cycle or "", + *row.fuel_types, + *row.mod_sources, + *(variant.config_name or "" for variant in row.family.variants), + ] + return any(normalized in target.casefold() for target in search_targets if target) + + +def _decimal_sort_key(value: Decimal | None, descending: bool = False) -> tuple[int, Decimal]: + if value is None: + return (1, Decimal("0")) + return (0, -value if descending else value) + + +def _sort_catalog_rows(rows: list[EngineCatalogRow], sort_option: str) -> str: + allowed = {option["value"] for option in ENGINE_SORT_OPTIONS} + normalized = sort_option if sort_option in allowed else "engine_name_asc" + + if normalized == "engine_name_desc": + rows.sort(key=lambda row: row.family.engine_name.casefold(), reverse=True) + return normalized + + if normalized == "vac_thrust_asc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.vac_thrust_kn), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "vac_thrust_desc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.vac_thrust_kn, descending=True), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "sl_thrust_asc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.sl_thrust_kn), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "sl_thrust_desc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.sl_thrust_kn, descending=True), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "mass_t_asc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.mass_t), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "mass_t_desc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.mass_t, descending=True), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "vac_isp_asc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.vac_isp), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "vac_isp_desc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.vac_isp, descending=True), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "sl_isp_asc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.sl_isp), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "sl_isp_desc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.sl_isp, descending=True), + row.family.engine_name.casefold(), + ) + ) + return normalized + + rows.sort(key=lambda row: row.family.engine_name.casefold()) + return normalized + + +def _build_engine_sort_links(query_args: dict[str, object], current_sort: str) -> list[dict[str, object]]: + columns = [] + for column in ENGINE_SORT_COLUMNS: + asc_value = f"{column['key']}_asc" + desc_value = f"{column['key']}_desc" + columns.append( + { + "key": column["key"], + "label": column["label"], + "asc_url": _page_url("web.engine_list", {**query_args, "sort": asc_value}, 1), + "desc_url": _page_url("web.engine_list", {**query_args, "sort": desc_value}, 1), + "asc_active": current_sort == asc_value, + "desc_active": current_sort == desc_value, + } + ) + return columns + + +def _sort_communications(rows: list[CommunicationPart], sort_option: str) -> str: + allowed = {option["value"] for option in COMM_SORT_OPTIONS} + normalized = sort_option if sort_option in allowed else "part_name" + + if normalized == "range_desc": + rows.sort( + key=lambda item: ( + _decimal_sort_key(item.range_km, descending=True), + item.part_name.casefold(), + ) + ) + return normalized + + if normalized == "mass_desc": + rows.sort( + key=lambda item: ( + _decimal_sort_key(item.mass_t, descending=True), + item.part_name.casefold(), + ) + ) + return normalized + + rows.sort(key=lambda item: item.part_name.casefold()) + return normalized + + +def _sort_tanks(rows: list[TankSpec], sort_option: str) -> str: + allowed = {option["value"] for option in TANK_SORT_OPTIONS} + normalized = sort_option if sort_option in allowed else "tank_name" + + if normalized == "volume_desc": + rows.sort( + key=lambda item: ( + _decimal_sort_key(item.tank_volume_l, descending=True), + item.tank_name.casefold(), + ) + ) + return normalized + + if normalized == "ratio_desc": + rows.sort( + key=lambda item: ( + _decimal_sort_key(item.mass_ratio, descending=True), + item.tank_name.casefold(), + ) + ) + return normalized + + rows.sort(key=lambda item: item.tank_name.casefold()) + return normalized + + +def _sort_vehicles(rows: list[VehicleCost], sort_option: str) -> str: + allowed = {option["value"] for option in VEHICLE_SORT_OPTIONS} + normalized = sort_option if sort_option in allowed else "vehicle_name" + + if normalized == "launch_price_desc": + rows.sort( + key=lambda item: ( + item.launch_price is None, + -(item.launch_price or 0), + item.vehicle_name.casefold(), + ) + ) + return normalized + + if normalized == "launch_price_asc": + rows.sort( + key=lambda item: ( + item.launch_price is None, + item.launch_price or 0, + item.vehicle_name.casefold(), + ) + ) + return normalized + + rows.sort(key=lambda item: item.vehicle_name.casefold()) + return normalized + + +def _sort_assets(rows: list[Asset], sort_option: str) -> str: + allowed = {option["value"] for option in ASSET_SORT_OPTIONS} + normalized = sort_option if sort_option in allowed else "name_asc" + + if normalized == "updated_desc": + rows.sort( + key=lambda item: ( + _as_utc(item.updated_at) or datetime.min.replace(tzinfo=timezone.utc), + item.name.casefold(), + ), + reverse=True, + ) + elif normalized == "asset_type_asc": + rows.sort(key=lambda item: ((item.asset_type or "").casefold(), item.name.casefold())) + else: + rows.sort(key=lambda item: item.name.casefold()) + + rows.sort(key=lambda item: bool(item.is_retired)) + return normalized + + +def _classify_asset_entry(entry: AssetLogEntry, simulation_time: datetime) -> dict[str, str]: + start_at = _as_utc(entry.start_at) + end_at = _as_utc(entry.end_at) + + if entry.entry_kind == "state": + if start_at and start_at <= simulation_time and (end_at is None or simulation_time < end_at): + return {"label": "Current Interval", "tone": "active"} + if start_at and simulation_time < start_at: + return {"label": "Upcoming Interval", "tone": "planned"} + return {"label": "Past Interval", "tone": "completed"} + + if start_at and simulation_time < start_at: + return {"label": "Upcoming Event", "tone": "planned"} + return {"label": "Past Event", "tone": "completed"} + + +def _entry_matches_date_range( + entry: AssetLogEntry, + range_start: datetime | None, + range_end: datetime | None, +) -> bool: + start_at = _as_utc(entry.start_at) + if start_at is None: + return False + + window_start = range_start or datetime.min.replace(tzinfo=timezone.utc) + window_end = range_end or datetime.max.replace(tzinfo=timezone.utc) + + if entry.entry_kind == "event": + return window_start <= start_at <= window_end + + effective_end = _as_utc(entry.end_at) or datetime.max.replace(tzinfo=timezone.utc) + return start_at <= window_end and effective_end >= window_start + + +def _build_asset_snapshot(asset: Asset, simulation_time: datetime) -> dict[str, object]: + entries = sorted(asset.log_entries, key=lambda item: _as_utc(item.start_at) or _default_simulation_time()) + active_state = None + for entry in entries: + if entry.entry_kind != "state": + continue + start_at = _as_utc(entry.start_at) + end_at = _as_utc(entry.end_at) + if start_at and start_at <= simulation_time and (end_at is None or simulation_time < end_at): + active_state = entry + + past_entries = [entry for entry in entries if (_as_utc(entry.start_at) or simulation_time) <= simulation_time] + last_entry = past_entries[-1] if past_entries else None + future_entries = [entry for entry in entries if (_as_utc(entry.start_at) or simulation_time) > simulation_time] + next_entry = future_entries[0] if future_entries else None + pending_state_nodes = [ + node + for node in sorted( + active_state.state_nodes if active_state is not None else [], + key=lambda item: _as_utc(item.at_time) or simulation_time, + ) + if (_as_utc(node.at_time) or simulation_time) > simulation_time + ] + + if active_state is not None: + current_state = _display_multi_value_text(active_state.state_label) or active_state.title + current_location = active_state.location or asset.home_region or "-" + current_mission = _display_multi_value_text(active_state.mission_label) or asset.program or "-" + state_window = f"{_format_datetime_display(active_state.start_at)} → {_format_datetime_display(active_state.end_at) if active_state.end_at else 'Open'}" + else: + current_state = "No Active Interval" if last_entry is not None else "No Logged Timeline" + current_location = ( + (last_entry.location if last_entry is not None else None) + or asset.home_region + or "-" + ) + current_mission = ( + (_display_multi_value_text(last_entry.mission_label) if last_entry is not None else None) + or asset.program + or "-" + ) + if last_entry is None: + state_window = "No entry covers selected Simulation Time" + elif last_entry.entry_kind == "state": + state_window = ( + f"Last interval · {_format_datetime_display(last_entry.start_at)}" + f" → {_format_datetime_display(last_entry.end_at) if last_entry.end_at else 'Open'}" + ) + else: + state_window = f"Last event · {_format_datetime_display(last_entry.start_at)}" + + current_event = active_state.title if active_state is not None else (last_entry.title if last_entry is not None else "No Logged Timeline") + + next_event_label = "No future event recorded" + upcoming_events: list[dict[str, str]] = [] + for state_node in pending_state_nodes: + upcoming_events.append( + { + "when": _format_datetime_display(state_node.at_time), + "title": state_node.title, + "scope": active_state.title if active_state is not None else asset.name, + } + ) + + for entry in future_entries: + upcoming_events.append( + { + "when": _format_datetime_display(entry.start_at), + "title": entry.title, + "scope": entry.location or _display_multi_value_text(entry.mission_label) or asset.name, + } + ) + + if upcoming_events: + next_event_label = f"{upcoming_events[0]['title']} · {upcoming_events[0]['when']}" + + return { + "current_state": current_state, + "current_event": current_event, + "current_location": current_location, + "current_mission": current_mission, + "state_window": state_window, + "next_event": next_event_label, + "next_entry": next_entry, + "active_state": active_state, + "last_entry": last_entry, + "upcoming_events": upcoming_events[:4], + } + + +def _build_asset_catalog_row(asset: Asset, simulation_time: datetime) -> dict[str, object]: + snapshot = _build_asset_snapshot(asset, simulation_time) + return { + "asset": asset, + "is_retired": asset.is_retired, + "current_event": snapshot["current_event"], + "current_location": snapshot["current_location"], + "next_event": snapshot["next_event"], + "entry_count": len(asset.log_entries), + } + + +def _load_assets_with_logs() -> list[Asset]: + return db.session.execute( + select(Asset).options(selectinload(Asset.log_entries).selectinload(AssetLogEntry.state_nodes)).order_by(Asset.name.asc()) + ).scalars().all() + + +def _classify_asset_board_record(snapshot: dict[str, object]) -> dict[str, object]: + if snapshot["active_state"] is not None: + return {"label": "Active", "rank": 0} + if snapshot["next_entry"] is not None: + return {"label": "Planned", "rank": 1} + if snapshot["last_entry"] is not None: + return {"label": "Recorded", "rank": 2} + return {"label": "Unlogged", "rank": 3} + + +def _derive_status_group_title(snapshot: dict[str, object], record_status: dict[str, object]) -> str: + if snapshot["active_state"] is not None: + return str(snapshot["current_state"]) + if record_status["label"] == "Planned": + return "Upcoming / Not Yet Active" + if record_status["label"] == "Recorded": + return "No Active Interval" + return "No Logged Timeline" + + +def _status_group_accent(group_title: str, record_label: str) -> str: + normalized = group_title.casefold() + if record_label == "Planned": + return "warning" + if "transit" in normalized or "transfer" in normalized or "cruise" in normalized: + return "warning" + if "station" in normalized or "dock" in normalized or "orbit" in normalized: + return "primary" + if "surface" in normalized or "port" in normalized or "outpost" in normalized or "base" in normalized: + return "neutral" + if "no active" in normalized or "no logged" in normalized: + return "soft" + if record_label == "Active": + return "primary" + if record_label == "Recorded": + return "soft" + return "neutral" + + +def _asset_board_summary(asset: Asset, snapshot: dict[str, object]) -> str: + summary_source = snapshot["active_state"] or snapshot["last_entry"] or snapshot["next_entry"] + if summary_source is not None and summary_source.summary: + return summary_source.summary + if asset.note: + return asset.note + if snapshot["last_entry"] is not None: + return f"Last logged entry: {snapshot['last_entry'].title}." + if snapshot["next_entry"] is not None: + return f"Next recorded entry: {snapshot['next_entry'].title}." + return "No summary recorded." + + +def _normalize_location_group(location: str) -> str: + if not location or location == "-": + return "Unknown Location" + return location + + +def _build_mission_board_rows(assets: list[Asset], simulation_time: datetime) -> list[dict[str, object]]: + rows = [] + for asset in assets: + snapshot = _build_asset_snapshot(asset, simulation_time) + record_status = _classify_asset_board_record(snapshot) + state_group = _derive_status_group_title(snapshot, record_status) + current_location = _normalize_location_group(str(snapshot["current_location"])) + state_display = state_group if snapshot["active_state"] is None else str(snapshot["current_state"]) + rows.append( + { + "asset": asset, + "snapshot": snapshot, + "record_status": record_status, + "name": asset.name, + "asset_id": asset.id, + "asset_type": asset.asset_type, + "location": current_location, + "mission": snapshot["current_mission"], + "state_window": snapshot["state_window"], + "next_event": snapshot["next_event"], + "summary": _asset_board_summary(asset, snapshot), + "status_group": state_group, + "status_accent": _status_group_accent(state_group, str(record_status["label"])), + "state_display": state_display, + } + ) + return rows + + +def _normalize_record_scope(raw_value: str | None) -> str: + allowed = {option["value"] for option in MISSION_BOARD_RECORD_SCOPE_OPTIONS} + normalized = _normalized_text(raw_value) or "all" + return normalized if normalized in allowed else "all" + + +def _matches_record_scope(row: dict[str, object], record_scope: str) -> bool: + record_label = row["record_status"]["label"] + if record_scope == "active": + return record_label == "Active" + if record_scope == "active_planned": + return record_label in {"Active", "Planned"} + if record_scope == "recorded": + return record_label == "Recorded" + return True + + +def _filter_mission_board_rows( + rows: list[dict[str, object]], + asset_type_filter: str, + location_filter: str, + record_scope: str, +) -> list[dict[str, object]]: + filtered_rows = [] + for row in rows: + if asset_type_filter and asset_type_filter != (row["asset_type"] or ""): + continue + if location_filter and location_filter != row["location"]: + continue + if not _matches_record_scope(row, record_scope): + continue + filtered_rows.append(row) + return filtered_rows + + +def _build_status_board_groups(rows: list[dict[str, object]]) -> list[dict[str, object]]: + groups_by_title: dict[str, dict[str, object]] = {} + for row in rows: + group = groups_by_title.setdefault( + row["status_group"], + { + "title": row["status_group"], + "accent": row["status_accent"], + "rank": row["record_status"]["rank"], + "items": [], + }, + ) + group["rank"] = min(group["rank"], row["record_status"]["rank"]) + group["items"].append( + { + "asset_id": row["asset_id"], + "name": row["name"], + "asset_type": row["asset_type"], + "location": row["location"], + "mission": row["mission"], + "state_window": row["state_window"], + "next_event": row["next_event"], + "summary": row["summary"], + "record_status": row["record_status"]["label"], + "record_sort_rank": row["record_status"]["rank"], + } + ) + + groups = list(groups_by_title.values()) + for group in groups: + group["items"].sort(key=lambda item: (item["record_sort_rank"], item["name"].casefold())) + groups.sort(key=lambda item: (item["rank"], item["title"].casefold())) + return groups + + +def _build_location_board_groups(rows: list[dict[str, object]]) -> list[dict[str, object]]: + groups_by_location: dict[str, dict[str, object]] = {} + for row in rows: + group = groups_by_location.setdefault( + row["location"], + { + "title": row["location"], + "subtitle": "Derived from active interval or latest logged event.", + "items": [], + }, + ) + group["items"].append( + { + "asset_id": row["asset_id"], + "name": row["name"], + "asset_type": row["asset_type"], + "state": row["state_display"], + "mission": row["mission"], + "location": row["location"], + "next_event": row["next_event"], + "record_sort_rank": row["record_status"]["rank"], + } + ) + + groups = list(groups_by_location.values()) + for group in groups: + item_count = len(group["items"]) + if group["title"] == "Unknown Location": + group["subtitle"] = "No active interval or logged location is available yet." + elif item_count == 1: + group["subtitle"] = "1 asset currently attributed to this location." + else: + group["subtitle"] = f"{item_count} assets currently attributed to this location." + group["items"].sort(key=lambda item: (item["record_sort_rank"], item["name"].casefold())) + groups.sort(key=lambda item: (item["title"] == "Unknown Location", item["title"].casefold())) + return groups + + +def _build_board_upcoming_events(rows: list[dict[str, object]], simulation_time: datetime) -> list[dict[str, str]]: + future_entries = [] + for row in rows: + asset = row["asset"] + for entry in asset.log_entries: + start_at = _as_utc(entry.start_at) + if start_at is None or start_at <= simulation_time: + continue + future_entries.append( + { + "when": _format_datetime_display(entry.start_at), + "title": f"{asset.name} · {entry.title}", + "scope": entry.location or row["location"], + "sort_at": start_at, + } + ) + + future_entries.sort(key=lambda item: item["sort_at"]) + return [ + {"when": item["when"], "title": item["title"], "scope": item["scope"]} + for item in future_entries[:6] + ] + + +def _build_board_metrics(all_rows: list[dict[str, object]], visible_rows: list[dict[str, object]], simulation_time: datetime) -> list[dict[str, str]]: + upcoming_window_limit = simulation_time + timedelta(days=30) + upcoming_30d_count = 0 + for row in visible_rows: + for entry in row["asset"].log_entries: + start_at = _as_utc(entry.start_at) + if start_at is not None and simulation_time < start_at <= upcoming_window_limit: + upcoming_30d_count += 1 + + return [ + {"label": "Total Assets", "value": str(len(all_rows))}, + {"label": "Visible Cards", "value": str(len(visible_rows))}, + {"label": "30-Day Events", "value": str(upcoming_30d_count)}, + ] + + +def _build_asset_catalog_metrics(all_assets: list[Asset], visible_assets: list[Asset], simulation_time: datetime) -> list[dict[str, str]]: + upcoming_window_limit = simulation_time + timedelta(days=30) + upcoming_30d_count = 0 + for asset in visible_assets: + for entry in asset.log_entries: + start_at = _as_utc(entry.start_at) + if start_at is not None and simulation_time < start_at <= upcoming_window_limit: + upcoming_30d_count += 1 + + return [ + {"label": "Total Assets", "value": str(len(all_assets))}, + {"label": "Current Results", "value": str(len(visible_assets))}, + {"label": "30-Day Events", "value": str(upcoming_30d_count)}, + ] + + +def _build_header_hidden_fields(values: dict[str, object]) -> list[dict[str, str]]: + hidden_fields: list[dict[str, str]] = [] + for name, value in values.items(): + if value in (None, "", [], (), set()): + continue + if isinstance(value, (list, tuple, set)): + for item in value: + if item in (None, ""): + continue + hidden_fields.append({"name": name, "value": str(item)}) + continue + hidden_fields.append({"name": name, "value": str(value)}) + return hidden_fields + + +def _mission_board_query_args( + simulation_time: datetime, + asset_type_filter: str, + location_filter: str, + record_scope: str, +) -> dict[str, str]: + params = { + "sim_time": _format_datetime_input(simulation_time), + "record_scope": record_scope, + } + if asset_type_filter: + params["asset_type"] = asset_type_filter + if location_filter: + params["location"] = location_filter + return params + + +def _mission_board_context( + endpoint: str, + simulation_time: datetime, + asset_type_filter: str, + location_filter: str, + record_scope: str, + all_rows: list[dict[str, object]], + visible_rows: list[dict[str, object]], +) -> dict[str, object]: + query_args = _mission_board_query_args(simulation_time, asset_type_filter, location_filter, record_scope) + return { + "simulation_time": _format_datetime_display(simulation_time), + "simulation_time_display": _format_datetime_display(simulation_time), + "simulation_time_input": _format_datetime_input(simulation_time), + "metrics": _build_board_metrics(all_rows, visible_rows, simulation_time), + "preview_eyebrow": "Mission Ops", + "header_time_action": url_for(endpoint), + "header_time_field_id": f"{endpoint.rsplit('.', 1)[-1]}-header-sim-time", + "header_time_submit_label": "Apply Time", + "header_hidden_fields": _build_header_hidden_fields( + { + "asset_type": asset_type_filter, + "location": location_filter, + "record_scope": record_scope, + } + ), + "upcoming_events": _build_board_upcoming_events(visible_rows, simulation_time), + "asset_type_filter": asset_type_filter, + "location_filter": location_filter, + "record_scope": record_scope, + "asset_type_options": sorted({row["asset_type"] for row in all_rows if row["asset_type"]}, key=str.casefold), + "location_options": sorted({row["location"] for row in all_rows if row["location"]}, key=str.casefold), + "record_scope_options": MISSION_BOARD_RECORD_SCOPE_OPTIONS, + "time_shift_urls": { + "minus_7": url_for(endpoint, **{**query_args, "sim_time": _format_datetime_input(simulation_time - timedelta(days=7))}), + "plus_7": url_for(endpoint, **{**query_args, "sim_time": _format_datetime_input(simulation_time + timedelta(days=7))}), + }, + "reset_url": url_for(endpoint, sim_time=_format_datetime_input(simulation_time)), + "board_nav_urls": { + "status": url_for("web.mission_status_board_preview", **query_args), + "location": url_for("web.mission_location_board_preview", **query_args), + "mission": url_for("web.mission_list", sim_time=_format_datetime_input(simulation_time)), + "asset": url_for("web.asset_list", sim_time=_format_datetime_input(simulation_time)), + "timeline": url_for("web.mission_timeline_preview", sim_time=_format_datetime_input(simulation_time)), + }, + } + + +def _mission_ops_nav_urls(simulation_time: datetime) -> dict[str, str]: + simulation_time_input = _format_datetime_input(simulation_time) + return { + "status": url_for("web.mission_status_board_preview", sim_time=simulation_time_input), + "location": url_for("web.mission_location_board_preview", sim_time=simulation_time_input), + "mission": url_for("web.mission_list", sim_time=simulation_time_input), + "asset": url_for("web.asset_list", sim_time=simulation_time_input), + "timeline": url_for("web.mission_timeline_preview", sim_time=simulation_time_input), + } + + +def _month_start(value: datetime) -> datetime: + normalized = _as_utc(value) or _default_simulation_time() + return normalized.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + +def _month_end(value: datetime) -> datetime: + start = _month_start(value) + if start.month == 12: + return start.replace(year=start.year + 1, month=1) + return start.replace(month=start.month + 1) + + +def _month_delta(start: datetime, end: datetime) -> int: + return (end.year - start.year) * 12 + (end.month - start.month) + + +def _day_start(value: datetime) -> datetime: + normalized = _as_utc(value) or _default_simulation_time() + return normalized.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _days_in_month(year: int, month: int) -> int: + if month == 12: + next_month = datetime(year + 1, 1, 1) + else: + next_month = datetime(year, month + 1, 1) + return (next_month - datetime(year, month, 1)).days + + +def _timeline_add_units(value: datetime, scale: str, count: int = 1) -> datetime: + normalized = _as_utc(value) or _default_simulation_time() + if scale == "day": + return normalized + timedelta(days=count) + if scale == "year": + target_year = normalized.year + count + target_day = min(normalized.day, _days_in_month(target_year, normalized.month)) + return normalized.replace(year=target_year, day=target_day) + + total_month = (normalized.year * 12 + (normalized.month - 1)) + count + target_year = total_month // 12 + target_month = (total_month % 12) + 1 + target_day = min(normalized.day, _days_in_month(target_year, target_month)) + return normalized.replace(year=target_year, month=target_month, day=target_day) + + +def _timeline_bucket_start(value: datetime, scale: str) -> datetime: + normalized = _as_utc(value) or _default_simulation_time() + if scale == "year": + return normalized.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) + if scale == "day": + return _day_start(normalized) + return _month_start(normalized) + + +def _timeline_next_bucket(value: datetime, scale: str) -> datetime: + bucket_start = _timeline_bucket_start(value, scale) + if scale == "year": + return bucket_start.replace(year=bucket_start.year + 1) + if scale == "day": + return bucket_start + timedelta(days=1) + return _month_end(bucket_start) + + +def _timeline_bucket_delta(start: datetime, end: datetime, scale: str) -> int: + start_bucket = _timeline_bucket_start(start, scale) + end_bucket = _timeline_bucket_start(end, scale) + if scale == "year": + return end_bucket.year - start_bucket.year + if scale == "day": + return (end_bucket - start_bucket).days + return _month_delta(start_bucket, end_bucket) + + +def _resolve_timeline_scale(raw_value: str | None, range_start: datetime, range_end: datetime) -> str: + allowed = {option["value"] for option in TIMELINE_SCALE_OPTIONS} + normalized = _normalized_text(raw_value) + if normalized in allowed: + return str(normalized) + + total_days = max(1, int((_timeline_bucket_start(range_end, "day") - _timeline_bucket_start(range_start, "day")).days) + 1) + if total_days <= 120: + return "day" + if total_days <= 365 * 4: + return "month" + return "year" + + +def _resolve_timeline_boundary(raw_value: str | None, fallback: datetime) -> datetime: + try: + return _parse_datetime(raw_value, "Timeline Boundary") or fallback + except ValueError: + return fallback + + +def _timeline_default_range(rows: list[dict[str, object]], simulation_time: datetime) -> tuple[datetime, datetime]: + return _timeline_default_range_for_scale(rows, simulation_time, "month") + + +def _timeline_default_range_for_scale(rows: list[dict[str, object]], simulation_time: datetime, scale: str) -> tuple[datetime, datetime]: + start_timestamps = [] + effective_end_timestamps = [] + for row in rows: + for entry in row["asset"].log_entries: + start_at = _as_utc(entry.start_at) + end_at = _as_utc(entry.end_at) + if start_at is not None: + start_timestamps.append(start_at) + effective_end_timestamps.append(end_at or start_at) + + all_timestamps = [*start_timestamps, *effective_end_timestamps] + if not all_timestamps: + fallback_start = _day_start(simulation_time) + return fallback_start, _timeline_add_units(fallback_start, scale) + + earliest_start = min(start_timestamps or all_timestamps) + latest_finish = max(effective_end_timestamps or all_timestamps) + return earliest_start, _timeline_add_units(latest_finish, scale) + + +def _timeline_scale_labels(start_at: datetime, end_at: datetime, scale: str) -> list[str]: + labels = [] + cursor = _timeline_bucket_start(start_at, scale) + boundary = _timeline_bucket_start(end_at, scale) + while cursor <= boundary: + if scale == "year": + labels.append(cursor.strftime("%Y")) + elif scale == "day": + labels.append(cursor.strftime("%Y-%m-%d")) + else: + labels.append(cursor.strftime("%Y-%m")) + cursor = _timeline_next_bucket(cursor, scale) + return labels + + +def _timeline_range_display(start_at: datetime, end_at: datetime, scale: str) -> str: + start_bucket = _timeline_bucket_start(start_at, scale) + end_bucket = _timeline_bucket_start(end_at, scale) + if scale == "year": + return f"{start_bucket.strftime('%Y')} to {end_bucket.strftime('%Y')}" + if scale == "day": + return f"{start_bucket.strftime('%Y-%m-%d')} to {end_bucket.strftime('%Y-%m-%d')}" + return f"{start_bucket.strftime('%Y-%m')} to {end_bucket.strftime('%Y-%m')}" + + +def _timeline_column_width(scale: str) -> int: + if scale == "day": + return 48 + if scale == "year": + return 84 + return 72 + + +def _timeline_segment_tone(entry: AssetLogEntry, simulation_time: datetime) -> str: + title = (entry.title or "").casefold() + location = (entry.location or "").casefold() + tone = "neutral" + if any(token in title for token in ("transfer", "transit", "outbound", "return", "departure")): + tone = "warning" + elif any(token in title for token in ("operation", "ops", "orbit", "station", "trial", "expedition")): + tone = "primary" + elif any(token in title for token in ("maintenance", "upgrade", "construction", "fitting", "reconfig")): + tone = "soft" + elif any(token in location for token in ("mars", "europa", "jupiter", "saturn", "neptune", "surface", "orbit")): + tone = "primary" + + start_at = _as_utc(entry.start_at) + end_at = _as_utc(entry.end_at) + if start_at is not None and start_at <= simulation_time and (end_at is None or simulation_time < end_at): + return f"{tone} current" + return tone + + +def _entry_overlaps_timeline(entry: AssetLogEntry, range_start: datetime, range_end: datetime) -> bool: + start_at = _as_utc(entry.start_at) + end_at = _as_utc(entry.end_at) + if start_at is None: + return False + if entry.entry_kind == "event": + return range_start <= start_at <= range_end + effective_end = end_at or range_end + return start_at <= range_end and effective_end > range_start + + +def _timeline_entry_edit_url(asset_id: object, entry_id: object, simulation_time: datetime) -> str: + return url_for( + "web.asset_entry_edit", + asset_id=asset_id, + entry_id=entry_id, + sim_time=_format_datetime_input(simulation_time), + ) + + +def _timeline_state_windows(asset: Asset) -> dict[object, tuple[datetime, datetime | None]]: + state_entries = [ + entry + for entry in sorted(asset.log_entries, key=lambda item: _as_utc(item.start_at) or _default_simulation_time()) + if entry.entry_kind == "state" and _as_utc(entry.start_at) is not None + ] + + windows: dict[object, tuple[datetime, datetime | None]] = {} + for entry in state_entries: + start_at = _as_utc(entry.start_at) + if start_at is None: + continue + + windows[entry.id] = (start_at, _as_utc(entry.end_at)) + + return windows + + +def _timeline_state_is_active( + entry: AssetLogEntry, + at_time: datetime, + state_windows: dict[object, tuple[datetime, datetime | None]], +) -> bool: + start_at, end_at = state_windows.get(entry.id, (_as_utc(entry.start_at) or at_time, _as_utc(entry.end_at))) + return start_at <= at_time and (end_at is None or at_time < end_at) + + +def _timeline_state_overlaps( + entry: AssetLogEntry, + range_start: datetime, + range_end: datetime, + state_windows: dict[object, tuple[datetime, datetime | None]], +) -> bool: + start_at, end_at = state_windows.get(entry.id, (_as_utc(entry.start_at) or range_start, _as_utc(entry.end_at))) + effective_end = end_at or range_end + return start_at <= range_end and effective_end > range_start + + +def _timeline_position(value: datetime, bucket_origin: datetime, scale: str, bucket_count: int) -> float: + normalized = _as_utc(value) or bucket_origin + if normalized <= bucket_origin: + return 0.0 + + bucket_start = _timeline_bucket_start(normalized, scale) + bucket_end = _timeline_next_bucket(bucket_start, scale) + bucket_index = _timeline_bucket_delta(bucket_origin, bucket_start, scale) + bucket_duration = max((bucket_end - bucket_start).total_seconds(), 1.0) + fraction = max(0.0, min(1.0, (normalized - bucket_start).total_seconds() / bucket_duration)) + return max(0.0, min(float(bucket_count), bucket_index + fraction)) + + +def _timeline_percent(position: float, bucket_count: int) -> str: + if bucket_count <= 0: + return "0%" + return f"{(position / bucket_count) * 100:.6f}%" + + +def _assign_timeline_tracks( + items: list[dict[str, object]], + *, + start_key: str, + end_key: str, + minimum_span: float, + gap: float = 0.0, + span_key: str | None = None, +) -> int: + if not items: + return 1 + + track_ends: list[float] = [] + for item in items: + start_position = float(item[start_key]) + item_span = minimum_span + if span_key is not None: + span_value = item.get(span_key) + if span_value is not None: + item_span = max(item_span, float(span_value)) + + end_position = max(float(item[end_key]), start_position + item_span) + track_index = 0 + while track_index < len(track_ends) and start_position < track_ends[track_index] + gap: + track_index += 1 + + if track_index == len(track_ends): + track_ends.append(end_position) + else: + track_ends[track_index] = end_position + + item["track"] = track_index + + return max(1, len(track_ends)) + + +def _timeline_state_track_settings(scale: str) -> tuple[float, float]: + if scale == "year": + return 0.9, 0.04 + if scale == "day": + return 0.18, 0.02 + return 0.24, 0.03 + + +def _timeline_event_track_settings(scale: str) -> tuple[float, float]: + label_span = 110 / max(_timeline_column_width(scale), 1) + if scale == "year": + return max(0.95, label_span), 0.08 + if scale == "day": + return max(1.75, label_span), 0.04 + return max(1.35, label_span), 0.06 + + +def _timeline_event_label_width_px(label: str, scale: str) -> int: + weighted_length = 0.0 + for character in label.strip(): + if character.isspace(): + weighted_length += 0.3 + elif ord(character) < 128: + weighted_length += 0.85 + else: + weighted_length += 1.55 + + if scale == "year": + minimum_width, maximum_width = 110, 190 + elif scale == "day": + minimum_width, maximum_width = 150, 260 + else: + minimum_width, maximum_width = 132, 220 + + estimated_width = int(28 + weighted_length * 7) + return max(minimum_width, min(maximum_width, estimated_width)) + + +def _timeline_event_label_span(width_px: int, scale: str) -> float: + return width_px / max(_timeline_column_width(scale), 1) + + +def _timeline_state_lane_height(track_count: int) -> int: + return max(1, track_count) * 38 + 22 + + +def _timeline_state_segment_center(track_index: int) -> int: + return 25 + max(track_index, 0) * 38 + + +def _timeline_event_node_anchor(track_index: int) -> int: + return 23 + max(track_index, 0) * 54 + + +def _timeline_state_node_connector_height( + state_track_count: int, + state_track_index: int, + event_track_index: int, +) -> int: + return ( + _timeline_state_lane_height(state_track_count) + + 8 + + _timeline_event_node_anchor(event_track_index) + - _timeline_state_segment_center(state_track_index) + ) + + +def _build_timeline_rows( + rows: list[dict[str, object]], + simulation_time: datetime, + range_start: datetime, + range_end: datetime, + scale: str, +) -> tuple[list[dict[str, object]], dict[str, int]]: + bucket_origin = _timeline_bucket_start(range_start, scale) + bucket_count = len(_timeline_scale_labels(range_start, range_end, scale)) + timeline_rows = [] + state_count = 0 + event_count = 0 + + for row in rows: + asset = row["asset"] + state_windows = _timeline_state_windows(asset) + segments = [] + events = [] + + for entry in sorted(asset.log_entries, key=lambda item: _as_utc(item.start_at) or range_start): + if entry.entry_kind == "state": + if not _timeline_state_overlaps(entry, range_start, range_end, state_windows): + continue + + state_start_at, state_end_at = state_windows.get(entry.id, (_as_utc(entry.start_at) or range_start, _as_utc(entry.end_at))) + start_at = max(state_start_at, range_start) + exclusive_end = state_end_at + if exclusive_end is not None: + exclusive_end = exclusive_end - timedelta(microseconds=1) + end_at = min(exclusive_end or range_end, range_end) + if end_at < start_at: + end_at = start_at + + actual_start_position = _timeline_position(start_at, bucket_origin, scale, bucket_count) + actual_end_position = _timeline_position(end_at, bucket_origin, scale, bucket_count) + if actual_end_position <= actual_start_position: + actual_end_position = min(float(bucket_count), actual_start_position + 0.02) + + state_count += 1 + segments.append( + { + "entry_id": entry.id, + "label": entry.title, + "tone": _timeline_segment_tone(entry, simulation_time), + "url": _timeline_entry_edit_url(entry.asset_id, entry.id, simulation_time), + "start_position": actual_start_position, + "body_end_position": actual_end_position, + "display_end_position": actual_end_position, + } + ) + + for state_node in sorted(entry.state_nodes, key=lambda item: _as_utc(item.at_time) or range_start): + node_at = _as_utc(state_node.at_time) + if node_at is None or node_at < range_start or node_at > range_end: + continue + + full_label_parts = [state_node.title, entry.title] + if state_node.detail: + full_label_parts.append(state_node.detail) + + event_position = _timeline_position(node_at, bucket_origin, scale, bucket_count) + label_width_px = _timeline_event_label_width_px(state_node.title, scale) + label_span = _timeline_event_label_span(label_width_px, scale) + label_side = "left" if event_position >= bucket_count * 0.6 else "right" + track_start_position = max(0.0, event_position - label_span) if label_side == "left" else event_position + track_end_position = event_position if label_side == "left" else min(float(bucket_count), event_position + label_span) + + events.append( + { + "label": state_node.title, + "full_label": " · ".join(full_label_parts), + "url": _timeline_entry_edit_url(asset.id, entry.id, simulation_time), + "position": event_position, + "label_width_px": label_width_px, + "label_span": label_span, + "label_side": label_side, + "track_start_position": track_start_position, + "track_end_position": track_end_position, + "state_node": True, + "parent_entry_id": entry.id, + } + ) + event_count += 1 + continue + + if not _entry_overlaps_timeline(entry, range_start, range_end): + continue + + event_position = _timeline_position(_as_utc(entry.start_at) or range_start, bucket_origin, scale, bucket_count) + label_width_px = _timeline_event_label_width_px(entry.title, scale) + label_span = _timeline_event_label_span(label_width_px, scale) + label_side = "left" if event_position >= bucket_count * 0.6 else "right" + track_start_position = max(0.0, event_position - label_span) if label_side == "left" else event_position + track_end_position = event_position if label_side == "left" else min(float(bucket_count), event_position + label_span) + events.append( + { + "label": entry.title, + "full_label": entry.title, + "url": _timeline_entry_edit_url(asset.id, entry.id, simulation_time), + "position": event_position, + "label_width_px": label_width_px, + "label_span": label_span, + "label_side": label_side, + "track_start_position": track_start_position, + "track_end_position": track_end_position, + "state_node": False, + } + ) + event_count += 1 + + segments.sort(key=lambda item: float(item["start_position"])) + events.sort(key=lambda item: float(item["position"])) + + state_minimum_span, state_gap = _timeline_state_track_settings(scale) + event_minimum_span, event_gap = _timeline_event_track_settings(scale) + state_track_count = _assign_timeline_tracks( + segments, + start_key="start_position", + end_key="display_end_position", + minimum_span=state_minimum_span, + gap=state_gap, + ) + segment_tracks = {segment["entry_id"]: int(segment["track"]) for segment in segments} + event_track_count = _assign_timeline_tracks( + events, + start_key="track_start_position", + end_key="track_end_position", + minimum_span=event_minimum_span, + gap=event_gap, + ) + + for segment in segments: + display_span = max(float(segment["display_end_position"]) - float(segment["start_position"]), 0.02) + body_span = max(float(segment["body_end_position"]) - float(segment["start_position"]), 0.02) + segment["left_pct"] = _timeline_percent(float(segment["start_position"]), bucket_count) + segment["track_width_pct"] = _timeline_percent(display_span, bucket_count) + segment["body_width_pct"] = f"{(body_span / display_span) * 100:.6f}%" + segment["compact"] = (scale == "year" and body_span < 0.45) or (state_track_count > 1 and body_span < 0.75) + + for event in events: + event["left_pct"] = _timeline_percent(float(event["position"]), bucket_count) + if not event.get("state_node"): + continue + + parent_track = segment_tracks.get(event["parent_entry_id"]) + if parent_track is None: + continue + + event["connector_height_px"] = f"{_timeline_state_node_connector_height(state_track_count, parent_track, int(event['track']))}px" + + if not segments and not events: + continue + + timeline_rows.append( + { + "asset_id": asset.id, + "name": asset.name, + "asset_type": asset.asset_type, + "current_state": row["state_display"], + "segments": segments, + "events": events, + "state_track_count": state_track_count, + "event_track_count": event_track_count, + } + ) + + timeline_rows.sort(key=lambda item: item["name"].casefold()) + return timeline_rows, {"states": state_count, "events": event_count} + + +def _page_url(endpoint: str, query_args: dict[str, object], page_number: int) -> str: + params = {key: value for key, value in query_args.items() if value not in (None, "")} + params["page"] = page_number + return url_for(endpoint, **params) + + +def _page_markers(total_pages: int, current_page: int) -> list[int | None]: + if total_pages <= 9: + return list(range(1, total_pages + 1)) + + markers: list[int | None] = [1] + start = max(2, current_page - 2) + end = min(total_pages - 1, current_page + 2) + + if start > 2: + markers.append(None) + markers.extend(range(start, end + 1)) + if end < total_pages - 1: + markers.append(None) + + markers.append(total_pages) + return markers + + +def _paginate( + items: list[Any], + endpoint: str, + query_args: dict[str, object], + requested_page: int, +) -> tuple[list[Any], dict[str, Any]]: + total_count = len(items) + total_pages = max(1, ceil(total_count / DEFAULT_PAGE_SIZE)) + page = min(max(requested_page, 1), total_pages) + start_index = (page - 1) * DEFAULT_PAGE_SIZE + page_items = items[start_index : start_index + DEFAULT_PAGE_SIZE] + + markers = _page_markers(total_pages, page) + page_links = [] + for marker in markers: + if marker is None: + page_links.append({"ellipsis": True}) + continue + page_links.append( + { + "ellipsis": False, + "number": marker, + "url": _page_url(endpoint, query_args, marker), + "current": marker == page, + } + ) + + context = { + "page": page, + "total_pages": total_pages, + "total_count": total_count, + "has_prev": page > 1, + "has_next": page < total_pages, + "prev_url": _page_url(endpoint, query_args, page - 1) if page > 1 else None, + "next_url": _page_url(endpoint, query_args, page + 1) if page < total_pages else None, + "page_links": page_links, + } + return page_items, context + + +def _entity_matches_query(values: list[str | None], search_term: str) -> bool: + normalized = search_term.casefold() + return any(normalized in (value or "").casefold() for value in values) + + +def _split_multi_value_text(value: str | None) -> list[str]: + if value is None: + return [] + + labels: list[str] = [] + for part in str(value).split(";"): + cleaned = _normalized_text(part) + if cleaned is None or cleaned in labels: + continue + labels.append(cleaned) + return labels + + +def _normalize_multi_value_text(value: str | None) -> str | None: + labels = _split_multi_value_text(value) + if not labels: + return None + return "; ".join(labels) + + +def _display_multi_value_text(value: str | None, separator: str = " · ") -> str | None: + labels = _split_multi_value_text(value) + if not labels: + return None + return separator.join(labels) + + +def _mission_label_search_key(value: str | None) -> str: + return re.sub(r"[\s\-_]+", "", (value or "").casefold()) + + +def _mission_label_matches_query(mission_label: str | None, search_term: str) -> bool: + if mission_label is None: + return False + + raw_query = search_term.casefold().strip() + raw_label = mission_label.casefold().strip() + if not raw_query or not raw_label: + return False + + normalized_query = _mission_label_search_key(search_term) + normalized_label = _mission_label_search_key(mission_label) + if normalized_query and normalized_query in normalized_label: + return True + if raw_query in raw_label: + return True + + query_tokens = [token for token in re.split(r"[\s\-_]+", raw_query) if token] + label_tokens = [token for token in re.split(r"[\s\-_]+", raw_label) if token] + if query_tokens and all( + any( + token in label_token + or label_token in token + or SequenceMatcher(None, token, label_token).ratio() >= 0.86 + for label_token in label_tokens + ) + for token in query_tokens + ): + return True + return False + + +@web_bp.get("/") +def dashboard() -> str: + return render_template( + "dashboard.html", + title="Dashboard", + metrics=PROJECT_METRICS, + modules=MODULES, + duplicate_conflicts=48, + duplicate_conflicts_with_fuel=19, + ) + + +@web_bp.get("/fuel-converter") +def fuel_converter() -> str: + return render_template( + "fuel_converter.html", + title="Fuel Converter", + fuel_factors=list_fuel_factors(), + ) + + +@web_bp.get("/engines") +def engine_list() -> str: + search_term = request.args.get("q", "").strip() + fuel_filter = request.args.get("fuel_type", "").strip() + cycle_filter = request.args.get("cycle", "").strip() + mod_source_filter = request.args.get("mod_source", "").strip() + sort_option = request.args.get("sort", "engine_name_asc").strip() + requested_page = request.args.get("page", default=1, type=int) or 1 + + catalog_rows = _build_catalog_rows() + fuel_options = sorted({fuel for row in catalog_rows for fuel in row.fuel_types}, key=str.casefold) + cycle_options = sorted({row.family.cycle for row in catalog_rows if row.family.cycle}, key=str.casefold) + mod_source_options = sorted({source for row in catalog_rows for source in row.mod_sources}, key=str.casefold) + + filtered_rows = [] + for row in catalog_rows: + if search_term and not _matches_catalog_search(row, search_term): + continue + if fuel_filter and fuel_filter not in row.fuel_types: + continue + if cycle_filter and cycle_filter != (row.family.cycle or ""): + continue + if mod_source_filter and mod_source_filter not in row.mod_sources: + continue + filtered_rows.append(row) + + normalized_sort = _sort_catalog_rows(filtered_rows, sort_option) + query_args = { + "q": search_term, + "fuel_type": fuel_filter, + "cycle": cycle_filter, + "mod_source": mod_source_filter, + } + page_rows, paging = _paginate(filtered_rows, "web.engine_list", query_args, requested_page) + sort_columns = _build_engine_sort_links(query_args, normalized_sort) + + return render_template( + "engine_list.html", + title="Engine Catalog", + catalog_rows=page_rows, + search_term=search_term, + fuel_filter=fuel_filter, + cycle_filter=cycle_filter, + mod_source_filter=mod_source_filter, + sort_option=normalized_sort, + sort_columns=sort_columns, + fuel_options=fuel_options, + cycle_options=cycle_options, + mod_source_options=mod_source_options, + total_catalog_count=len(catalog_rows), + **paging, + ) + + +@web_bp.route("/engines/new", methods=["GET", "POST"]) +def engine_new() -> str | object: + family = EngineFamily() + variant = EngineVariant() + form_options = _engine_form_options() + + if request.method == "POST": + try: + _apply_fields(family, request.form, FAMILY_FORM_FIELDS) + _apply_fields(variant, request.form, VARIANT_CREATE_FIELDS, prefix="initial-") + _normalize_variant_ignitions(variant) + variant.family = family + db.session.add(family) + db.session.add(variant) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + except IntegrityError: + db.session.rollback() + flash("创建失败:Engine 名称可能已存在。", "error") + else: + flash("引擎已创建。", "success") + return redirect(url_for("web.engine_detail", family_id=family.id)) + + return render_template( + "engine_create.html", + title="Create Engine", + family_fields=FAMILY_FORM_FIELDS, + variant_fields=VARIANT_CREATE_FIELDS, + form_options=form_options, + custom_option_value=CUSTOM_OPTION_VALUE, + ) + + +@web_bp.get("/engines/") +def engine_detail(family_id: object) -> str: + family = _load_engine_family_or_404(family_id) + variant_rows = [ + { + "variant": variant, + "sl_thrust_kn": _estimate_sl_thrust_kn(variant), + "vac_thrust_kn": variant.max_thrust_kn, + } + for variant in family.variants + ] + return render_template( + "engine_detail.html", + title=family.engine_name, + family=family, + variant_rows=variant_rows, + ) + + +@web_bp.route("/engines//edit", methods=["GET", "POST"]) +def engine_edit(family_id: object) -> str: + family = _load_engine_family_or_404(family_id) + form_options = _engine_form_options() + + if request.method == "POST": + try: + _apply_fields(family, request.form, FAMILY_FORM_FIELDS) + for variant in family.variants: + _apply_fields( + variant, + request.form, + VARIANT_FORM_FIELDS, + prefix=f"variant-{variant.id}-", + ) + _normalize_variant_ignitions(variant) + _refresh_variant_derived_fields(variant) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + except IntegrityError: + db.session.rollback() + flash("保存失败:Engine 名称必须唯一。", "error") + else: + flash("引擎数据已保存。", "success") + return redirect(url_for("web.engine_detail", family_id=family.id)) + + return render_template( + "engine_edit.html", + title=f"Edit · {family.engine_name}", + family=family, + family_fields=FAMILY_FORM_FIELDS, + variant_fields=VARIANT_FORM_FIELDS, + variant_create_fields=VARIANT_CREATE_FIELDS, + form_options=form_options, + custom_option_value=CUSTOM_OPTION_VALUE, + ) + + +@web_bp.post("/engines//variants/add") +def engine_variant_add(family_id: object) -> object: + family = _load_engine_family_or_404(family_id) + variant = EngineVariant(family=family) + + try: + _apply_fields(variant, request.form, VARIANT_CREATE_FIELDS, prefix="add-") + _normalize_variant_ignitions(variant) + db.session.add(variant) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("已新增配置项。", "success") + + return redirect(url_for("web.engine_edit", family_id=family.id)) + + +@web_bp.post("/engines//variants//delete") +def engine_variant_delete(family_id: object, variant_id: object) -> object: + family = _load_engine_family_or_404(family_id) + variant = db.session.get(EngineVariant, variant_id) + if variant is None or variant.family_id != family.id: + abort(404) + + db.session.delete(variant) + db.session.commit() + flash("配置项已删除。", "success") + return redirect(url_for("web.engine_edit", family_id=family.id)) + + +@web_bp.post("/engines//delete") +def engine_delete(family_id: object) -> object: + family = _load_engine_family_or_404(family_id) + db.session.delete(family) + db.session.commit() + flash(f"已删除引擎:{family.engine_name}", "success") + return redirect(url_for("web.engine_list")) + + +@web_bp.get("/communications") +def communication_list() -> str: + search_term = request.args.get("q", "").strip() + source_filter = request.args.get("source", "").strip() + type_filter = request.args.get("antenna_type", "").strip() + sort_option = request.args.get("sort", "part_name").strip() + requested_page = request.args.get("page", default=1, type=int) or 1 + + rows = db.session.execute(select(CommunicationPart).order_by(CommunicationPart.part_name.asc())).scalars().all() + + source_options = sorted({row.source for row in rows if row.source}, key=str.casefold) + type_options = sorted({row.antenna_type for row in rows if row.antenna_type}, key=str.casefold) + + filtered_rows = [] + for row in rows: + if search_term and not _entity_matches_query( + [row.part_name, row.display_name, row.source, row.antenna_type], + search_term, + ): + continue + if source_filter and source_filter != (row.source or ""): + continue + if type_filter and type_filter != (row.antenna_type or ""): + continue + filtered_rows.append(row) + + normalized_sort = _sort_communications(filtered_rows, sort_option) + query_args = { + "q": search_term, + "source": source_filter, + "antenna_type": type_filter, + "sort": normalized_sort, + } + page_rows, paging = _paginate(filtered_rows, "web.communication_list", query_args, requested_page) + + return render_template( + "communication_list.html", + title="Communication Parts", + rows=page_rows, + search_term=search_term, + source_filter=source_filter, + antenna_type_filter=type_filter, + sort_option=normalized_sort, + sort_options=COMM_SORT_OPTIONS, + source_options=source_options, + type_options=type_options, + total_catalog_count=len(rows), + **paging, + ) + + +@web_bp.route("/communications/new", methods=["GET", "POST"]) +def communication_new() -> str | object: + part = CommunicationPart() + + if request.method == "POST": + try: + _apply_fields(part, request.form, COMMUNICATION_FIELDS) + db.session.add(part) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("已新增通信部件。", "success") + return redirect(url_for("web.communication_edit", part_id=part.id)) + + return render_template( + "resource_edit.html", + title="Create Communication", + eyebrow="Communication", + heading="新增通信部件", + description="新建一条通信部件记录。", + fields=COMMUNICATION_FIELDS, + values=_collect_form_values(part, COMMUNICATION_FIELDS), + cancel_url=url_for("web.communication_list"), + submit_label="创建", + delete_url=None, + ) + + +@web_bp.route("/communications//edit", methods=["GET", "POST"]) +def communication_edit(part_id: object) -> str | object: + part = _load_model_or_404(CommunicationPart, part_id) + + if request.method == "POST": + try: + _apply_fields(part, request.form, COMMUNICATION_FIELDS) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("通信部件已保存。", "success") + return redirect(url_for("web.communication_list")) + + return render_template( + "resource_edit.html", + title=f"Edit · {part.part_name}", + eyebrow="Communication", + heading=f"编辑 {part.part_name}", + description="修改通信部件字段。", + fields=COMMUNICATION_FIELDS, + values=_collect_form_values(part, COMMUNICATION_FIELDS), + cancel_url=url_for("web.communication_list"), + submit_label="保存", + delete_url=url_for("web.communication_delete", part_id=part.id), + delete_label="删除通信部件", + delete_confirm="确认删除这个通信部件?", + ) + + +@web_bp.post("/communications//delete") +def communication_delete(part_id: object) -> object: + part = _load_model_or_404(CommunicationPart, part_id) + db.session.delete(part) + db.session.commit() + flash("通信部件已删除。", "success") + return redirect(url_for("web.communication_list")) + + +@web_bp.get("/tanks") +def tank_list() -> str: + search_term = request.args.get("q", "").strip() + fuel_filter = request.args.get("fuel_type", "").strip() + source_filter = request.args.get("source", "").strip() + sort_option = request.args.get("sort", "tank_name").strip() + requested_page = request.args.get("page", default=1, type=int) or 1 + + rows = db.session.execute(select(TankSpec).order_by(TankSpec.tank_name.asc())).scalars().all() + + fuel_options = sorted({row.fuel_type for row in rows if row.fuel_type}, key=str.casefold) + source_options = sorted({row.source for row in rows if row.source}, key=str.casefold) + + filtered_rows = [] + for row in rows: + if search_term and not _entity_matches_query( + [row.tank_name, row.vehicle_name, row.source, row.fuel_type], + search_term, + ): + continue + if fuel_filter and fuel_filter != (row.fuel_type or ""): + continue + if source_filter and source_filter != (row.source or ""): + continue + filtered_rows.append(row) + + normalized_sort = _sort_tanks(filtered_rows, sort_option) + query_args = { + "q": search_term, + "fuel_type": fuel_filter, + "source": source_filter, + "sort": normalized_sort, + } + page_rows, paging = _paginate(filtered_rows, "web.tank_list", query_args, requested_page) + + return render_template( + "tank_list.html", + title="Tank Specs", + rows=page_rows, + search_term=search_term, + fuel_filter=fuel_filter, + source_filter=source_filter, + sort_option=normalized_sort, + sort_options=TANK_SORT_OPTIONS, + fuel_options=fuel_options, + source_options=source_options, + total_catalog_count=len(rows), + **paging, + ) + + +@web_bp.route("/tanks/new", methods=["GET", "POST"]) +def tank_new() -> str | object: + tank = TankSpec() + + if request.method == "POST": + try: + _apply_fields(tank, request.form, TANK_FIELDS) + db.session.add(tank) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("已新增燃料箱记录。", "success") + return redirect(url_for("web.tank_edit", tank_id=tank.id)) + + return render_template( + "resource_edit.html", + title="Create Tank", + eyebrow="Tank", + heading="新增燃料箱记录", + description="录入一条 Tank Chart 数据。", + fields=TANK_FIELDS, + values=_collect_form_values(tank, TANK_FIELDS), + cancel_url=url_for("web.tank_list"), + submit_label="创建", + delete_url=None, + ) + + +@web_bp.route("/tanks//edit", methods=["GET", "POST"]) +def tank_edit(tank_id: object) -> str | object: + tank = _load_model_or_404(TankSpec, tank_id) + + if request.method == "POST": + try: + _apply_fields(tank, request.form, TANK_FIELDS) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("燃料箱记录已保存。", "success") + return redirect(url_for("web.tank_list")) + + return render_template( + "resource_edit.html", + title=f"Edit · {tank.tank_name}", + eyebrow="Tank", + heading=f"编辑 {tank.tank_name}", + description="修改燃料箱字段。", + fields=TANK_FIELDS, + values=_collect_form_values(tank, TANK_FIELDS), + cancel_url=url_for("web.tank_list"), + submit_label="保存", + delete_url=url_for("web.tank_delete", tank_id=tank.id), + delete_label="删除燃料箱记录", + delete_confirm="确认删除这个燃料箱记录?", + ) + + +@web_bp.post("/tanks//delete") +def tank_delete(tank_id: object) -> object: + tank = _load_model_or_404(TankSpec, tank_id) + db.session.delete(tank) + db.session.commit() + flash("燃料箱记录已删除。", "success") + return redirect(url_for("web.tank_list")) + + +@web_bp.get("/vehicles") +def vehicle_list() -> str: + search_term = request.args.get("q", "").strip() + source_filter = request.args.get("source", "").strip() + sort_option = request.args.get("sort", "vehicle_name").strip() + requested_page = request.args.get("page", default=1, type=int) or 1 + + rows = db.session.execute(select(VehicleCost).order_by(VehicleCost.vehicle_name.asc())).scalars().all() + source_options = sorted({row.source for row in rows if row.source}, key=str.casefold) + + filtered_rows = [] + for row in rows: + if search_term and not _entity_matches_query([row.vehicle_name, row.source], search_term): + continue + if source_filter and source_filter != (row.source or ""): + continue + filtered_rows.append(row) + + normalized_sort = _sort_vehicles(filtered_rows, sort_option) + query_args = {"q": search_term, "source": source_filter, "sort": normalized_sort} + page_rows, paging = _paginate(filtered_rows, "web.vehicle_list", query_args, requested_page) + + return render_template( + "vehicle_list.html", + title="Vehicle Costs", + rows=page_rows, + search_term=search_term, + source_filter=source_filter, + sort_option=normalized_sort, + sort_options=VEHICLE_SORT_OPTIONS, + source_options=source_options, + total_catalog_count=len(rows), + **paging, + ) + + +@web_bp.route("/vehicles/new", methods=["GET", "POST"]) +def vehicle_new() -> str | object: + vehicle = VehicleCost() + + if request.method == "POST": + try: + _apply_fields(vehicle, request.form, VEHICLE_FIELDS) + db.session.add(vehicle) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("已新增载具成本记录。", "success") + return redirect(url_for("web.vehicle_edit", vehicle_id=vehicle.id)) + + return render_template( + "resource_edit.html", + title="Create Vehicle", + eyebrow="Vehicle", + heading="新增载具成本", + description="录入一条 Vehicle Cost 数据。", + fields=VEHICLE_FIELDS, + values=_collect_form_values(vehicle, VEHICLE_FIELDS), + cancel_url=url_for("web.vehicle_list"), + submit_label="创建", + delete_url=None, + ) + + +@web_bp.route("/vehicles//edit", methods=["GET", "POST"]) +def vehicle_edit(vehicle_id: object) -> str | object: + vehicle = _load_model_or_404(VehicleCost, vehicle_id) + + if request.method == "POST": + try: + _apply_fields(vehicle, request.form, VEHICLE_FIELDS) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("载具成本记录已保存。", "success") + return redirect(url_for("web.vehicle_list")) + + return render_template( + "resource_edit.html", + title=f"Edit · {vehicle.vehicle_name}", + eyebrow="Vehicle", + heading=f"编辑 {vehicle.vehicle_name}", + description="修改发射成本字段。", + fields=VEHICLE_FIELDS, + values=_collect_form_values(vehicle, VEHICLE_FIELDS), + cancel_url=url_for("web.vehicle_list"), + submit_label="保存", + delete_url=url_for("web.vehicle_delete", vehicle_id=vehicle.id), + delete_label="删除载具成本记录", + delete_confirm="确认删除这个载具成本记录?", + ) + + +@web_bp.post("/vehicles//delete") +def vehicle_delete(vehicle_id: object) -> object: + vehicle = _load_model_or_404(VehicleCost, vehicle_id) + db.session.delete(vehicle) + db.session.commit() + flash("载具成本记录已删除。", "success") + return redirect(url_for("web.vehicle_list")) + + +@web_bp.get("/assets") +def asset_list() -> str: + search_term = request.args.get("q", "").strip() + asset_type_filter = request.args.get("asset_type", "").strip() + sort_option = request.args.get("sort", "name_asc").strip() + requested_page = request.args.get("page", default=1, type=int) or 1 + simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + + rows = db.session.execute( + select(Asset).options(selectinload(Asset.log_entries)).order_by(Asset.name.asc()) + ).scalars().all() + asset_type_options = sorted({row.asset_type for row in rows if row.asset_type}, key=str.casefold) + + filtered_rows = [] + for row in rows: + if search_term and not _entity_matches_query([row.name, row.asset_type, row.program, row.home_region], search_term): + continue + if asset_type_filter and asset_type_filter != (row.asset_type or ""): + continue + filtered_rows.append(row) + + normalized_sort = _sort_assets(filtered_rows, sort_option) + query_args = { + "q": search_term, + "asset_type": asset_type_filter, + "sort": normalized_sort, + "sim_time": _format_datetime_input(simulation_time), + } + page_rows, paging = _paginate(filtered_rows, "web.asset_list", query_args, requested_page) + catalog_rows = [_build_asset_catalog_row(asset, simulation_time) for asset in page_rows] + + return render_template( + "asset_list.html", + title="任务资产", + preview_title="任务资产目录", + preview_eyebrow="Mission Asset", + rows=catalog_rows, + search_term=search_term, + asset_type_filter=asset_type_filter, + sort_option=normalized_sort, + sort_options=ASSET_SORT_OPTIONS, + asset_type_options=asset_type_options, + total_catalog_count=len(rows), + metrics=_build_asset_catalog_metrics(rows, filtered_rows, simulation_time), + preview_tab="asset", + board_nav_urls=_mission_ops_nav_urls(simulation_time), + simulation_time_display=_format_datetime_display(simulation_time), + simulation_time_input=_format_datetime_input(simulation_time), + header_time_action=url_for("web.asset_list"), + header_time_field_id="asset-list-header-sim-time", + header_time_submit_label="Apply Time", + header_hidden_fields=_build_header_hidden_fields( + { + "q": search_term, + "asset_type": asset_type_filter, + "sort": normalized_sort, + } + ), + **paging, + ) + + +@web_bp.get("/missions") +def mission_list() -> str: + simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + search_term = request.args.get("q", "").strip() + asset_id_filter = request.args.get("asset_id", "").strip() + mission_label_filter = request.args.get("mission_label", "").strip() + location_filter = request.args.get("location", "").strip() + + try: + range_start = _parse_datetime(request.args.get("start"), "任务开始时间") + except ValueError as exc: + range_start = None + flash(str(exc), "error") + + try: + range_end = _parse_datetime(request.args.get("end"), "任务结束时间") + except ValueError as exc: + range_end = None + flash(str(exc), "error") + + if range_start is not None and range_end is not None and range_end < range_start: + flash("任务结束时间不能早于开始时间。", "error") + range_end = range_start + + all_rows = [] + for asset in _load_assets_with_logs(): + if asset.is_retired: + continue + for entry in sorted( + asset.log_entries, + key=lambda item: _as_utc(item.start_at) or _default_simulation_time(), + reverse=True, + ): + mission_labels = _split_multi_value_text(entry.mission_label) + if not mission_labels: + continue + state_labels = _split_multi_value_text(entry.state_label) + + status = _classify_asset_entry(entry, simulation_time) + for mission_label in mission_labels: + all_rows.append( + { + "asset": asset, + "asset_id": str(asset.id), + "asset_name": asset.name, + "asset_type": asset.asset_type or "-", + "entry": entry, + "entry_id": entry.id, + "mission_label": mission_label, + "mission_labels": mission_labels, + "state_labels": state_labels, + "title": entry.title, + "mode_label": "Event Point" if entry.entry_kind == "event" else "State Interval", + "status_label": status["label"], + "status_tone": status["tone"], + "timestamp": _format_datetime_display(entry.start_at), + "end_timestamp": _format_datetime_display(entry.end_at) if entry.end_at else None, + "location": entry.location or "-", + "summary": entry.summary or "未填写摘要。", + "sort_at": _as_utc(entry.start_at) or _default_simulation_time(), + } + ) + + asset_options = [ + {"value": asset_id, "label": asset_name} + for asset_id, asset_name in sorted( + {item["asset_id"]: item["asset_name"] for item in all_rows}.items(), + key=lambda item: item[1].casefold(), + ) + ] + mission_label_options = sorted({row["mission_label"] for row in all_rows}, key=str.casefold) + location_options = sorted( + {row["location"] for row in all_rows if row["location"] and row["location"] != "-"}, + key=str.casefold, + ) + + filtered_rows = [] + for row in all_rows: + if asset_id_filter and asset_id_filter != row["asset_id"]: + continue + if mission_label_filter and mission_label_filter != row["mission_label"]: + continue + if location_filter and location_filter != row["location"]: + continue + if search_term and not _mission_label_matches_query(row["mission_label"], search_term): + continue + if not _entry_matches_date_range(row["entry"], range_start, range_end): + continue + filtered_rows.append(row) + + grouped_rows: dict[str, dict[str, object]] = {} + for row in filtered_rows: + mission_label = str(row["mission_label"]) + group = grouped_rows.setdefault( + mission_label, + { + "mission_label": mission_label, + "entries": [], + "asset_names": set(), + "locations": set(), + "active_count": 0, + "planned_count": 0, + "latest_at": row["sort_at"], + }, + ) + group["entries"].append(row) + group["asset_names"].add(str(row["asset_name"])) + if row["location"] != "-": + group["locations"].add(str(row["location"])) + if row["status_tone"] == "active": + group["active_count"] += 1 + elif row["status_tone"] == "planned": + group["planned_count"] += 1 + if row["sort_at"] > group["latest_at"]: + group["latest_at"] = row["sort_at"] + + mission_groups = [] + for group in grouped_rows.values(): + entries = sorted(group["entries"], key=lambda item: item["sort_at"], reverse=True) + asset_names = sorted(group["asset_names"], key=str.casefold) + locations = sorted(group["locations"], key=str.casefold) + if group["active_count"]: + group_status = "Active" + group_rank = 0 + elif group["planned_count"]: + group_status = "Upcoming" + group_rank = 1 + else: + group_status = "Recorded" + group_rank = 2 + mission_groups.append( + { + "mission_label": group["mission_label"], + "entries": entries, + "entry_count": len(entries), + "asset_names": asset_names, + "asset_count": len(asset_names), + "locations": locations or ["-"], + "status_label": group_status, + "group_rank": group_rank, + "latest_timestamp": _format_datetime_display(group["latest_at"]), + } + ) + + mission_groups.sort(key=lambda item: (item["group_rank"], item["mission_label"].casefold())) + selected_asset_name = next( + (option["label"] for option in asset_options if option["value"] == asset_id_filter), + "全部资产", + ) + + return render_template( + "mission_list.html", + title="任务运营 · 任务", + preview_title="任务", + preview_eyebrow="Mission Label", + preview_tab="mission", + mission_groups=mission_groups, + search_term=search_term, + asset_id_filter=asset_id_filter, + mission_label_filter=mission_label_filter, + location_filter=location_filter, + asset_options=asset_options, + mission_label_options=mission_label_options, + location_options=location_options, + selected_asset_name=selected_asset_name, + total_entry_count=len(all_rows), + filtered_entry_count=len(filtered_rows), + metrics=[ + {"label": "Visible Missions", "value": str(len(mission_groups))}, + {"label": "Visible Entries", "value": str(len(filtered_rows))}, + {"label": "Active Missions", "value": str(sum(1 for group in mission_groups if group["status_label"] == "Active"))}, + ], + simulation_time_display=_format_datetime_display(simulation_time), + simulation_time_input=_format_datetime_input(simulation_time), + range_start_input=_format_datetime_input(range_start), + range_end_input=_format_datetime_input(range_end), + header_time_action=url_for("web.mission_list"), + header_time_field_id="mission-list-header-sim-time", + header_time_submit_label="Apply Time", + header_hidden_fields=_build_header_hidden_fields( + { + "q": search_term, + "asset_id": asset_id_filter, + "mission_label": mission_label_filter, + "location": location_filter, + "start": _format_datetime_input(range_start), + "end": _format_datetime_input(range_end), + } + ), + board_nav_urls=_mission_ops_nav_urls(simulation_time), + ) + + +@web_bp.route("/assets/new", methods=["GET", "POST"]) +def asset_new() -> str | object: + asset = Asset() + form_options = _asset_form_options() + simulation_time = _resolve_simulation_time(request.form.get("sim_time") if request.method == "POST" else request.args.get("sim_time")) + simulation_time_input = _format_datetime_input(simulation_time) + + if request.method == "POST": + try: + _apply_fields(asset, request.form, ASSET_FIELDS) + db.session.add(asset) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + except IntegrityError: + db.session.rollback() + flash("创建失败:Asset 名称必须唯一。", "error") + else: + flash("资产已创建。", "success") + return redirect(url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input)) + + return render_template( + "resource_edit.html", + title="新增任务资产", + eyebrow="Mission Asset", + heading="新增任务资产", + description=None, + fields=ASSET_FIELDS, + values=_collect_form_values(asset, ASSET_FIELDS), + form_options=form_options, + custom_option_value=CUSTOM_OPTION_VALUE, + cancel_url=url_for("web.asset_list", sim_time=simulation_time_input), + submit_label="创建", + delete_url=None, + hidden_fields={"sim_time": simulation_time_input}, + mission_ops_tabs=True, + preview_tab="asset", + board_nav_urls=_mission_ops_nav_urls(simulation_time), + ) + + +@web_bp.route("/assets//edit", methods=["GET", "POST"]) +def asset_edit(asset_id: object) -> str | object: + asset = _load_asset_or_404(asset_id) + form_options = _asset_form_options() + simulation_time = _resolve_simulation_time(request.form.get("sim_time") if request.method == "POST" else request.args.get("sim_time")) + simulation_time_input = _format_datetime_input(simulation_time) + + if request.method == "POST": + try: + _apply_fields(asset, request.form, ASSET_FIELDS) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + except IntegrityError: + db.session.rollback() + flash("保存失败:Asset 名称必须唯一。", "error") + else: + flash("资产信息已保存。", "success") + return redirect(url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input)) + + return render_template( + "resource_edit.html", + title=f"编辑任务资产 · {asset.name}", + eyebrow="Mission Asset", + heading=f"编辑 {asset.name}", + description=None, + fields=ASSET_FIELDS, + values=_collect_form_values(asset, ASSET_FIELDS), + form_options=form_options, + custom_option_value=CUSTOM_OPTION_VALUE, + cancel_url=url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input), + submit_label="保存", + delete_url=url_for("web.asset_delete", asset_id=asset.id), + delete_label="删除资产", + delete_confirm="确认删除这个资产及其日志?", + hidden_fields={"sim_time": simulation_time_input}, + delete_hidden_fields={"sim_time": simulation_time_input}, + mission_ops_tabs=True, + preview_tab="asset", + board_nav_urls=_mission_ops_nav_urls(simulation_time), + ) + + +@web_bp.post("/assets//delete") +def asset_delete(asset_id: object) -> object: + asset = _load_asset_or_404(asset_id) + simulation_time_input = _normalized_text(request.form.get("sim_time")) + db.session.delete(asset) + db.session.commit() + flash("资产已删除。", "success") + redirect_kwargs: dict[str, str] = {} + if simulation_time_input is not None: + redirect_kwargs["sim_time"] = simulation_time_input + return redirect(url_for("web.asset_list", **redirect_kwargs)) + + +@web_bp.post("/assets/import-log-book") +def asset_import_log_book() -> object: + redirect_kwargs: dict[str, str] = {} + simulation_time_input = _normalized_text(request.form.get("sim_time")) + if simulation_time_input is not None: + redirect_kwargs["sim_time"] = simulation_time_input + + try: + summary = import_log_book_data(LOG_BOOK_PATH) + except FileNotFoundError as exc: + flash(str(exc), "error") + except Exception as exc: + flash(f"导入 log_book 失败:{exc}", "error") + else: + imported_sheet_names = ", ".join(item.sheet_name for item in summary.imported_assets) or "无" + total_states = sum(item.state_entries for item in summary.imported_assets) + total_events = sum(item.event_entries for item in summary.imported_assets) + flash( + f"已从 {summary.workbook} 导入 {len(summary.imported_assets)} 个载具 sheet({imported_sheet_names}),共 {total_states} 条 state 和 {total_events} 条 event。", + "success", + ) + + return redirect(url_for("web.asset_list", **redirect_kwargs)) + + +@web_bp.get("/assets/") +def asset_detail(asset_id: object) -> str: + asset = _load_asset_or_404(asset_id) + simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + requested_page = request.args.get("page", default=1, type=int) or 1 + snapshot = _build_asset_snapshot(asset, simulation_time) + + try: + entry_range_start = _parse_datetime(request.args.get("start"), "日志开始时间") + except ValueError as exc: + entry_range_start = None + flash(str(exc), "error") + + try: + entry_range_end = _parse_datetime(request.args.get("end"), "日志结束时间") + except ValueError as exc: + entry_range_end = None + flash(str(exc), "error") + + if entry_range_start is not None and entry_range_end is not None and entry_range_end < entry_range_start: + flash("日志结束时间不能早于开始时间。", "error") + entry_range_end = entry_range_start + + entry_rows = [] + filtered_entries = [ + entry + for entry in sorted( + asset.log_entries, + key=lambda item: _as_utc(item.start_at) or _default_simulation_time(), + reverse=True, + ) + if _entry_matches_date_range(entry, entry_range_start, entry_range_end) + ] + for entry in filtered_entries: + status = _classify_asset_entry(entry, simulation_time) + entry_rows.append( + { + "id": entry.id, + "mode": entry.entry_kind, + "mode_label": "Event Point" if entry.entry_kind == "event" else "State Interval", + "status_label": status["label"], + "title": entry.title, + "timestamp": _format_datetime_display(entry.start_at), + "end_timestamp": _format_datetime_display(entry.end_at) if entry.end_at else None, + "location": entry.location or "-", + "summary": entry.summary or "未填写摘要。", + "mission_label": _display_multi_value_text(entry.mission_label), + "state_label": _display_multi_value_text(entry.state_label), + "mission_labels": _split_multi_value_text(entry.mission_label), + "state_labels": _split_multi_value_text(entry.state_label), + } + ) + + paged_entries, pagination_context = _paginate( + entry_rows, + "web.asset_detail", + { + "asset_id": asset.id, + "sim_time": _format_datetime_input(simulation_time), + "start": _format_datetime_input(entry_range_start), + "end": _format_datetime_input(entry_range_end), + }, + requested_page, + ) + + return render_template( + "asset_detail.html", + title=asset.name, + preview_title=asset.name, + preview_eyebrow="Mission Asset", + asset=asset, + asset_summary=snapshot, + entries=paged_entries, + upcoming_events=snapshot["upcoming_events"], + total_entry_count=len(asset.log_entries), + filtered_entry_count=len(entry_rows), + metrics=[ + {"label": "Asset Type", "value": asset.asset_type}, + {"label": "Entries", "value": str(len(asset.log_entries))}, + {"label": "Current State", "value": str(snapshot["current_state"])}, + ], + entry_filter_start_input=_format_datetime_input(entry_range_start), + entry_filter_end_input=_format_datetime_input(entry_range_end), + entry_page=pagination_context["page"], + entry_total_pages=pagination_context["total_pages"], + entry_has_prev=pagination_context["has_prev"], + entry_has_next=pagination_context["has_next"], + entry_prev_url=pagination_context["prev_url"], + entry_next_url=pagination_context["next_url"], + entry_page_links=pagination_context["page_links"], + preview_tab="asset", + board_nav_urls=_mission_ops_nav_urls(simulation_time), + simulation_time_display=_format_datetime_display(simulation_time), + simulation_time_input=_format_datetime_input(simulation_time), + header_time_action=url_for("web.asset_detail", asset_id=asset.id), + header_time_field_id="asset-detail-header-sim-time", + header_time_submit_label="Apply Time", + header_hidden_fields=_build_header_hidden_fields( + { + "start": _format_datetime_input(entry_range_start), + "end": _format_datetime_input(entry_range_end), + "page": requested_page if requested_page > 1 else None, + } + ), + ) + + +@web_bp.route("/assets//entries/new", methods=["GET", "POST"]) +def asset_entry_new(asset_id: object) -> str | object: + asset = _load_asset_or_404(asset_id) + simulation_time = _resolve_simulation_time(request.form.get("sim_time") if request.method == "POST" else request.args.get("sim_time")) + simulation_time_input = _format_datetime_input(simulation_time) + requested_kind = _normalized_text(request.args.get("kind")) or "event" + if requested_kind not in {option["value"] for option in ASSET_ENTRY_KIND_OPTIONS}: + requested_kind = "event" + + entry = AssetLogEntry(entry_kind=requested_kind) + + if request.method == "POST": + try: + _apply_asset_entry_fields(entry, request.form) + entry.asset = asset + db.session.add(entry) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("日志条目已新增。", "success") + return redirect(url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input)) + + return render_template( + "asset_entry_form.html", + title=f"新增日志 · {asset.name}", + asset=asset, + heading=f"为 {asset.name} 新增日志条目", + description=None, + submit_label="创建条目", + form_values=_asset_entry_form_values(entry), + kind_options=ASSET_ENTRY_KIND_OPTIONS, + cancel_url=url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input), + delete_url=None, + simulation_time_input=simulation_time_input, + preview_tab="asset", + board_nav_urls=_mission_ops_nav_urls(simulation_time), + ) + + +@web_bp.route("/assets//entries//edit", methods=["GET", "POST"]) +def asset_entry_edit(asset_id: object, entry_id: object) -> str | object: + asset = _load_asset_or_404(asset_id) + entry = _load_asset_entry_or_404(asset.id, entry_id) + simulation_time = _resolve_simulation_time(request.form.get("sim_time") if request.method == "POST" else request.args.get("sim_time")) + simulation_time_input = _format_datetime_input(simulation_time) + + if request.method == "POST": + try: + _apply_asset_entry_fields(entry, request.form) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("日志条目已保存。", "success") + return redirect(url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input)) + + return render_template( + "asset_entry_form.html", + title=f"编辑日志 · {asset.name}", + asset=asset, + heading=f"编辑 {asset.name} 的日志条目", + description=None, + submit_label="保存条目", + form_values=_asset_entry_form_values(entry), + kind_options=ASSET_ENTRY_KIND_OPTIONS, + cancel_url=url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input), + delete_url=url_for("web.asset_entry_delete", asset_id=asset.id, entry_id=entry.id), + simulation_time_input=simulation_time_input, + preview_tab="asset", + board_nav_urls=_mission_ops_nav_urls(simulation_time), + ) + + +@web_bp.post("/assets//entries//delete") +def asset_entry_delete(asset_id: object, entry_id: object) -> object: + asset = _load_asset_or_404(asset_id) + entry = _load_asset_entry_or_404(asset.id, entry_id) + simulation_time_input = _normalized_text(request.form.get("sim_time")) + db.session.delete(entry) + db.session.commit() + flash("日志条目已删除。", "success") + redirect_kwargs: dict[str, str | object] = {"asset_id": asset.id} + if simulation_time_input is not None: + redirect_kwargs["sim_time"] = simulation_time_input + return redirect(url_for("web.asset_detail", **redirect_kwargs)) + + +@web_bp.get("/mission-preview") +def mission_preview_home() -> object: + return redirect(url_for("web.mission_status_board_preview")) + + +@web_bp.get("/mission-preview/status-board") +def mission_status_board_preview() -> str: + simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + asset_type_filter = request.args.get("asset_type", "").strip() + location_filter = request.args.get("location", "").strip() + record_scope = _normalize_record_scope(request.args.get("record_scope")) + + all_rows = [ + row + for row in _build_mission_board_rows(_load_assets_with_logs(), simulation_time) + if not row["asset"].is_retired + ] + visible_rows = _filter_mission_board_rows(all_rows, asset_type_filter, location_filter, record_scope) + + return render_template( + "mission_status_board_preview.html", + title="任务运营 · 状态板", + preview_title="状态板", + preview_tab="status", + status_groups=_build_status_board_groups(visible_rows), + **_mission_board_context( + "web.mission_status_board_preview", + simulation_time, + asset_type_filter, + location_filter, + record_scope, + all_rows, + visible_rows, + ), + ) + + +@web_bp.get("/mission-preview/location-board") +def mission_location_board_preview() -> str: + simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + asset_type_filter = request.args.get("asset_type", "").strip() + location_filter = request.args.get("location", "").strip() + record_scope = _normalize_record_scope(request.args.get("record_scope")) + + all_rows = [ + row + for row in _build_mission_board_rows(_load_assets_with_logs(), simulation_time) + if not row["asset"].is_retired + ] + visible_rows = _filter_mission_board_rows(all_rows, asset_type_filter, location_filter, record_scope) + + return render_template( + "mission_location_board_preview.html", + title="任务运营 · 地点板", + preview_title="地点板", + preview_tab="location", + location_groups=_build_location_board_groups(visible_rows), + **_mission_board_context( + "web.mission_location_board_preview", + simulation_time, + asset_type_filter, + location_filter, + record_scope, + all_rows, + visible_rows, + ), + ) + + +@web_bp.get("/mission-preview/asset-log") +def mission_asset_log_preview() -> object: + simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + return redirect(url_for("web.asset_list", sim_time=_format_datetime_input(simulation_time))) + + +@web_bp.get("/mission-preview/timeline") +def mission_timeline_preview() -> str: + simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + asset_type_filter = request.args.get("asset_type", "").strip() + location_filter = request.args.get("location", "").strip() + record_scope = _normalize_record_scope(request.args.get("record_scope")) + requested_asset_ids = {asset_id for asset_id in request.args.getlist("asset_ids") if asset_id} + + all_rows = _build_mission_board_rows(_load_assets_with_logs(), simulation_time) + filtered_rows = _filter_mission_board_rows(all_rows, asset_type_filter, location_filter, record_scope) + visible_rows = [ + row for row in filtered_rows if not requested_asset_ids or str(row["asset_id"]) in requested_asset_ids + ] + + default_rows = visible_rows or filtered_rows or all_rows + initial_range_start, initial_range_end = _timeline_default_range(default_rows, simulation_time) + timeline_scale_mode = _resolve_timeline_scale(request.args.get("scale"), initial_range_start, initial_range_end) + default_range_start, default_range_end = _timeline_default_range_for_scale(default_rows, simulation_time, timeline_scale_mode) + timeline_range_mode = "manual" if _normalized_text(request.args.get("range_mode")) == "manual" else "auto" + if timeline_range_mode == "manual": + range_start = _resolve_timeline_boundary(request.args.get("start"), default_range_start) + range_end = _resolve_timeline_boundary(request.args.get("end"), default_range_end) + else: + range_start = default_range_start + range_end = default_range_end + if range_end < range_start: + range_end = range_start + + timeline_scale = _timeline_scale_labels(range_start, range_end, timeline_scale_mode) + timeline_rows, timeline_counts = _build_timeline_rows( + visible_rows, + simulation_time, + range_start, + range_end, + timeline_scale_mode, + ) + timeline_column_width = _timeline_column_width(timeline_scale_mode) + + board_query_args = _mission_board_query_args(simulation_time, asset_type_filter, location_filter, record_scope) + timeline_query_args = {**board_query_args, "scale": timeline_scale_mode} + if requested_asset_ids: + timeline_query_args["asset_ids"] = sorted(requested_asset_ids) + if timeline_range_mode == "manual": + timeline_query_args["start"] = _format_datetime_input(range_start) + timeline_query_args["end"] = _format_datetime_input(range_end) + timeline_query_args["range_mode"] = timeline_range_mode + selected_asset_options = [ + { + "value": str(row["asset_id"]), + "label": row["name"], + "asset_type": row["asset_type"], + "selected": str(row["asset_id"]) in requested_asset_ids, + } + for row in sorted(filtered_rows, key=lambda item: item["name"].casefold()) + ] + + return render_template( + "mission_timeline_preview.html", + title="任务运营 · 时间线", + preview_title="时间线", + preview_eyebrow="Mission Ops", + preview_tab="timeline", + timeline_scale=timeline_scale, + timeline_rows=timeline_rows, + timeline_scale_mode=timeline_scale_mode, + timeline_scale_options=TIMELINE_SCALE_OPTIONS, + timeline_column_width=timeline_column_width, + timeline_range_start=_format_datetime_input(range_start), + timeline_range_end=_format_datetime_input(range_end), + timeline_range_mode=timeline_range_mode, + timeline_range_display=_timeline_range_display(range_start, range_end, timeline_scale_mode), + simulation_time=_format_datetime_display(simulation_time), + simulation_time_input=_format_datetime_input(simulation_time), + metrics=[ + {"label": "Visible Assets", "value": str(len(timeline_rows))}, + {"label": "State Segments", "value": str(timeline_counts["states"] )}, + {"label": "Event Nodes", "value": str(timeline_counts["events"] )}, + ], + header_time_action=url_for("web.mission_timeline_preview"), + header_time_field_id="timeline-header-sim-time", + header_time_submit_label="Apply Time", + header_hidden_fields=_build_header_hidden_fields( + { + "asset_type": asset_type_filter, + "location": location_filter, + "record_scope": record_scope, + "scale": timeline_scale_mode, + "asset_ids": sorted(requested_asset_ids), + "range_mode": timeline_range_mode if timeline_range_mode == "manual" else None, + "start": _format_datetime_input(range_start) if timeline_range_mode == "manual" else None, + "end": _format_datetime_input(range_end) if timeline_range_mode == "manual" else None, + } + ), + asset_type_filter=asset_type_filter, + location_filter=location_filter, + record_scope=record_scope, + selected_asset_ids=sorted(requested_asset_ids), + selected_asset_count=len(requested_asset_ids), + timeline_asset_options=selected_asset_options, + reset_url=url_for( + "web.mission_timeline_preview", + sim_time=_format_datetime_input(simulation_time), + asset_type=asset_type_filter or None, + location=location_filter or None, + record_scope=record_scope, + scale=timeline_scale_mode, + asset_ids=sorted(requested_asset_ids), + ), + asset_type_options=sorted({row["asset_type"] for row in all_rows if row["asset_type"]}, key=str.casefold), + location_options=sorted({row["location"] for row in all_rows if row["location"]}, key=str.casefold), + record_scope_options=MISSION_BOARD_RECORD_SCOPE_OPTIONS, + board_nav_urls={ + "status": url_for("web.mission_status_board_preview", **board_query_args), + "location": url_for("web.mission_location_board_preview", **board_query_args), + "mission": url_for("web.mission_list", sim_time=_format_datetime_input(simulation_time)), + "asset": url_for("web.asset_list", sim_time=_format_datetime_input(simulation_time)), + "timeline": url_for("web.mission_timeline_preview", **timeline_query_args), + }, + ) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..c5ae291 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Application service helpers.""" diff --git a/app/services/fuel_conversion.py b/app/services/fuel_conversion.py new file mode 100644 index 0000000..64a5018 --- /dev/null +++ b/app/services/fuel_conversion.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP + + +FUEL_FACTORS: list[tuple[str, Decimal]] = [ + ("HTPB", Decimal("0.00177")), + ("Solid", Decimal("0.00178")), + ("PBAN", Decimal("0.001772")), + ("Kerolox", Decimal("0.0010290673")), + ("Hydrolox", Decimal("0.00036235886")), + ("Methalox", Decimal("0.000845043157")), + ("Hydrazine", Decimal("0.001004")), + ("MMH/NTO", Decimal("0.00116557")), +] + +FUEL_DENSITY_T_PER_LITER = dict(FUEL_FACTORS) +VALUE_STEP = Decimal("0.000001") + + +def list_fuel_factors() -> list[dict[str, float | str]]: + return [ + {"fuel": fuel, "tonnes_per_liter": _to_float(factor)} + for fuel, factor in FUEL_FACTORS + ] + + +def convert_value(mode: str, raw_value: object) -> dict[str, object]: + value = _parse_positive_decimal(raw_value) + normalized_mode = mode.strip().lower() + + if normalized_mode == "volume": + results = [ + { + "fuel": fuel, + "value": _to_float(value * factor), + "unit": "t", + } + for fuel, factor in FUEL_FACTORS + ] + return { + "mode": normalized_mode, + "input_value": _to_float(value), + "input_unit": "L", + "results": results, + } + + if normalized_mode == "mass": + results = [ + { + "fuel": fuel, + "value": _to_float(value / factor), + "unit": "L", + } + for fuel, factor in FUEL_FACTORS + ] + return { + "mode": normalized_mode, + "input_value": _to_float(value), + "input_unit": "t", + "results": results, + } + + raise ValueError("mode must be either 'volume' or 'mass'") + + +def _parse_positive_decimal(raw_value: object) -> Decimal: + try: + value = Decimal(str(raw_value)) + except (InvalidOperation, ValueError, TypeError) as exc: + raise ValueError("value must be numeric") from exc + + if value <= 0: + raise ValueError("value must be greater than zero") + + return value + + +def _to_float(value: Decimal) -> float: + return float(value.quantize(VALUE_STEP, rounding=ROUND_HALF_UP)) diff --git a/app/services/log_book_importer.py b/app/services/log_book_importer.py new file mode 100644 index 0000000..68fcb00 --- /dev/null +++ b/app/services/log_book_importer.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +from openpyxl import load_workbook +from sqlalchemy import delete, select + +from app.extensions import db +from app.models import Asset, AssetLogEntry + + +EVENT_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2})\s+(.+)$") +LOG_BOOK_IGNORED_SHEETS = {"Overview", "Model"} + +BODY_LOCATION_KEYWORDS = [ + ("earth-sun l2", "Earth-Sun L2"), + ("earth-sun lagrange point 2", "Earth-Sun L2"), + ("star port", "Star Port"), + ("iapetus", "Iapetus"), + ("callisto", "Callisto"), + ("ganymede", "Ganymede"), + ("europa", "Europa"), + ("jupiter", "Jupiter"), + ("saturn", "Saturn"), + ("neptune", "Neptune"), + ("mars", "Mars"), + ("venus", "Venus"), + ("mercury", "Mercury"), + ("lunar orbit", "Lunar Orbit"), + ("leo", "LEO"), + ("earth orbit", "Earth"), + ("earth", "Earth"), + ("sun", "Sun"), +] + +LOG_BOOK_ASSET_SPECS = { + "ST-01": { + "name": "万星源号 ST-01", + "asset_type": "Exploration Mothership", + "program": "Stellaria", + "home_region": "LEO", + "note": "First Class of Stellaria", + }, + "XH-01": { + "name": "羲和号 XH-01", + "asset_type": "Exploration Mothership", + "program": "羲和计划", + "home_region": "LEO", + "note": "羲和计划首舰", + }, +} + + +@dataclass +class LogBookSheetSummary: + sheet_name: str + asset_name: str + state_entries: int = 0 + event_entries: int = 0 + + def to_dict(self) -> dict[str, object]: + return { + "sheet_name": self.sheet_name, + "asset_name": self.asset_name, + "state_entries": self.state_entries, + "event_entries": self.event_entries, + } + + +@dataclass +class LogBookImportSummary: + workbook: str + imported_assets: list[LogBookSheetSummary] = field(default_factory=list) + + def to_dict(self) -> dict[str, object]: + return { + "workbook": self.workbook, + "imported_assets": [item.to_dict() for item in self.imported_assets], + } + + +def import_log_book_data(workbook_path: str | Path) -> LogBookImportSummary: + path = Path(workbook_path) + if not path.exists(): + raise FileNotFoundError(f"Workbook not found: {path}") + + workbook = load_workbook(path, data_only=True, read_only=True) + summary = LogBookImportSummary(workbook=path.name) + + try: + for sheet_name in _iter_importable_sheet_names(workbook.sheetnames): + asset_spec = _resolve_asset_spec(sheet_name) + sheet_summary = _import_sheet(workbook[sheet_name], sheet_name, asset_spec) + summary.imported_assets.append(sheet_summary) + db.session.commit() + except Exception: + db.session.rollback() + raise + + return summary + + +def _iter_importable_sheet_names(sheet_names: list[str]) -> list[str]: + return [sheet_name for sheet_name in sheet_names if sheet_name not in LOG_BOOK_IGNORED_SHEETS] + + +def _resolve_asset_spec(sheet_name: str) -> dict[str, str | None]: + asset_spec: dict[str, str | None] = { + "name": sheet_name, + "asset_type": "Vehicle", + "program": None, + "home_region": None, + "note": f"Imported from log_book.xlsx sheet {sheet_name}.", + } + asset_spec.update(LOG_BOOK_ASSET_SPECS.get(sheet_name, {})) + return asset_spec + + +def _import_sheet(worksheet: object, sheet_name: str, asset_spec: dict[str, str]) -> LogBookSheetSummary: + asset = db.session.execute(select(Asset).where(Asset.name == asset_spec["name"])).scalar_one_or_none() + if asset is None: + asset = Asset( + name=asset_spec["name"], + asset_type=asset_spec["asset_type"], + program=asset_spec["program"], + home_region=asset_spec["home_region"], + note=asset_spec["note"], + ) + db.session.add(asset) + db.session.flush() + + asset.asset_type = asset_spec["asset_type"] + asset.program = asset_spec["program"] + asset.home_region = asset_spec["home_region"] + asset.note = asset_spec["note"] + + db.session.execute(delete(AssetLogEntry).where(AssetLogEntry.asset_id == asset.id)) + db.session.flush() + + summary = LogBookSheetSummary(sheet_name=sheet_name, asset_name=asset.name) + for row in worksheet.iter_rows(min_row=2, values_only=True): + if not any(value not in (None, "") for value in row): + continue + + log_name = _clean_text(row[0]) + start_at = _coerce_datetime(row[1]) + end_at = _coerce_datetime(row[2]) + mission_detail = _clean_text(row[3]) + sub_log = _clean_text(row[4]) + explicit_location = _clean_text(row[7]) + inferred_location = _infer_location(log_name, mission_detail, sub_log, explicit_location) + + if log_name is None or start_at is None: + continue + + db.session.add( + AssetLogEntry( + asset_id=asset.id, + entry_kind="state", + title=log_name, + state_label=log_name, + mission_label=log_name, + location=inferred_location, + start_at=start_at, + end_at=end_at, + summary=mission_detail, + note=sub_log, + ) + ) + summary.state_entries += 1 + + return summary + + +def _clean_text(value: object) -> str | None: + if value in (None, ""): + return None + cleaned = str(value).strip() + return cleaned or None + + +def _coerce_datetime(value: object) -> datetime | None: + if value in (None, ""): + return None + if isinstance(value, datetime): + normalized = value + else: + normalized = datetime.fromisoformat(str(value)) + if normalized.tzinfo is None: + return normalized.replace(tzinfo=timezone.utc) + return normalized.astimezone(timezone.utc) + + +def _infer_location( + log_name: str | None, + mission_detail: str | None, + sub_log: str | None, + explicit_location: str | None, +) -> str | None: + if explicit_location: + return explicit_location + + haystacks = [ + (log_name or "").casefold(), + (mission_detail or "").casefold(), + (sub_log or "").casefold(), + ] + for keyword, location in BODY_LOCATION_KEYWORDS: + if any(keyword in haystack for haystack in haystacks): + return location + + if mission_detail and "transfer" in mission_detail.casefold(): + return "Sun" + if log_name and "maintenance" in log_name.casefold(): + return "LEO" + if log_name and "construction" in log_name.casefold(): + return "LEO" + return None + + +def _infer_event_location(description: str, default_location: str | None) -> str | None: + lowered = description.casefold() + for keyword, location in BODY_LOCATION_KEYWORDS: + if keyword in lowered: + return location + return default_location + + +def _parse_sub_log_events( + asset_id: object, + parent_title: str, + parent_start_at: datetime, + parent_end_at: datetime | None, + default_location: str | None, + sub_log: str | None, +) -> list[AssetLogEntry]: + if sub_log is None: + return [] + + events = [] + for raw_line in sub_log.splitlines(): + line = raw_line.strip() + if not line: + continue + match = EVENT_LINE_PATTERN.match(line) + if match is None: + continue + + event_at = _coerce_datetime(match.group(1)) + if event_at is None: + continue + + # Skip obvious workbook typos that fall far outside the parent interval. + if event_at < parent_start_at.replace(year=parent_start_at.year - 1): + continue + if parent_end_at is not None and event_at > parent_end_at.replace(year=parent_end_at.year + 1): + continue + + description = match.group(2).strip() + events.append( + AssetLogEntry( + asset_id=asset_id, + entry_kind="event", + title=description, + state_label=None, + mission_label=parent_title, + location=_infer_event_location(description, default_location), + start_at=event_at, + end_at=None, + summary=parent_title, + note=line, + ) + ) + + return events \ No newline at end of file diff --git a/app/services/workbook_importer.py b/app/services/workbook_importer.py new file mode 100644 index 0000000..e8f4d9c --- /dev/null +++ b/app/services/workbook_importer.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation +from pathlib import Path + +from openpyxl import load_workbook +from sqlalchemy import delete, inspect + +from app.extensions import db +from app.models import CommunicationPart, EngineFamily, EngineVariant, TankSpec, VehicleCost + + +@dataclass +class WorkbookImportSummary: + workbook: str + replaced_existing: bool + imported_rows: dict[str, int] = field(default_factory=dict) + warnings: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, object]: + return { + "workbook": self.workbook, + "replaced_existing": self.replaced_existing, + "imported_rows": self.imported_rows, + "warnings": self.warnings, + } + + +def import_workbook_data( + workbook_path: str | Path, replace_existing: bool = False +) -> WorkbookImportSummary: + path = Path(workbook_path) + if not path.exists(): + raise FileNotFoundError(f"Workbook not found: {path}") + + _assert_schema_ready() + + summary = WorkbookImportSummary(workbook=path.name, replaced_existing=replace_existing) + workbook = load_workbook(path, data_only=True, read_only=True) + + try: + if replace_existing: + _clear_import_tables() + elif _database_has_data(): + raise ValueError("数据库已有数据。若要重导,请使用 --replace。") + + family_count, variant_count = _import_engines(workbook["Engine Database"], summary) + communication_count = _import_communication_parts(workbook["Communication"]) + tank_count = _import_tank_specs(workbook["Tank Chart"]) + vehicle_cost_count = _import_vehicle_costs(workbook["KSP Vehicle Cost"]) + + db.session.commit() + except Exception: + db.session.rollback() + raise + + summary.imported_rows = { + "engine_families": family_count, + "engine_variants": variant_count, + "communication_parts": communication_count, + "tank_specs": tank_count, + "vehicle_costs": vehicle_cost_count, + } + return summary + + +def _assert_schema_ready() -> None: + existing_tables = set(inspect(db.engine).get_table_names()) + required_tables = { + "engine_families", + "engine_variants", + "communication_parts", + "tank_specs", + "vehicle_costs", + } + missing_tables = sorted(required_tables - existing_tables) + if missing_tables: + missing = ", ".join(missing_tables) + raise ValueError(f"数据库缺少表:{missing}。请先执行数据库迁移。") + + +def _database_has_data() -> bool: + for model in (EngineFamily, EngineVariant, CommunicationPart, TankSpec, VehicleCost): + if db.session.query(model).first() is not None: + return True + return False + + +def _clear_import_tables() -> None: + for model in (EngineVariant, EngineFamily, CommunicationPart, TankSpec, VehicleCost): + db.session.execute(delete(model)) + db.session.flush() + + +def _import_engines(worksheet: object, summary: WorkbookImportSummary) -> tuple[int, int]: + families_by_name: dict[str, EngineFamily] = {} + variant_count = 0 + + for row_number, row in enumerate(worksheet.iter_rows(min_row=2, values_only=True), start=2): + if not _row_has_values(row): + continue + + engine_name = _text(row[0]) + if engine_name is None: + summary.warnings.append(f"Engine Database 第 {row_number} 行缺少 Engine,已跳过。") + continue + + family = families_by_name.get(engine_name) + if family is None: + family = EngineFamily( + engine_name=engine_name, + part_name=_text(row[18]), + cycle=_text(row[2]), + size_m=_decimal(row[12]), + entry_cost=_integer(row[14]), + ) + families_by_name[engine_name] = family + db.session.add(family) + else: + family.part_name = family.part_name or _text(row[18]) + family.cycle = family.cycle or _text(row[2]) + family.size_m = family.size_m or _decimal(row[12]) + family.entry_cost = family.entry_cost or _integer(row[14]) + + fuel_type = _text(row[1]) + if fuel_type is None: + summary.warnings.append(f"Engine Database 第 {row_number} 行缺少 Fuel Type,已跳过。") + continue + + min_thrust = _decimal(row[6]) + max_thrust = _decimal(row[7]) + mass = _decimal(row[8]) + + variant = EngineVariant( + family=family, + fuel_type=fuel_type, + work_env=_text(row[3]), + sl_isp=_decimal(row[4]), + vac_isp=_decimal(row[5]), + min_thrust_kn=min_thrust, + max_thrust_kn=max_thrust, + mass_t=mass, + twr=_decimal(row[9]) or _calculate_twr(max_thrust, mass), + throttle_ratio=_decimal(row[10]) or _calculate_throttle_ratio(min_thrust, max_thrust), + tvc_deg=_decimal(row[11]), + price_kd=_integer(row[13]), + ignitions=_integer(row[15]), + has_unlimited_ignitions=_integer(row[15]) is None, + mod_source=_text(row[16]), + engine_note=_text(row[17]), + config_name=_text(row[19]), + tech_required=_text(row[20]), + config_note=_text(row[21]), + source_sheet_row=row_number, + ) + db.session.add(variant) + variant_count += 1 + + return len(families_by_name), variant_count + + +def _import_communication_parts(worksheet: object) -> int: + count = 0 + for row in worksheet.iter_rows(min_row=2, values_only=True): + if not _row_has_values(row): + continue + + part_name = _text(row[0]) + if part_name is None: + continue + + db.session.add( + CommunicationPart( + part_name=part_name, + display_name=_text(row[1]), + mass_t=_decimal(row[2]), + is_active=_boolean(row[3]), + is_deployable=_boolean(row[4]), + antenna_type=_text(row[5]), + deployed_diameter_m=_decimal(row[6]), + range_raw=_decimal(row[7]), + range_km=_decimal(row[8]), + range_au=_decimal(row[9]), + range_light_year=_decimal(row[10]), + angle_deg=_decimal(row[11]), + speed=_decimal(row[12]), + idle_power_watt=_decimal(row[13]), + idle_power_text=_text(row[14]), + transmitting_power_watt=_decimal(row[15]), + transmitting_power_text=_text(row[16]), + source=_text(row[17]), + entry_cost=_integer(row[18]), + cost=_integer(row[19]), + description=_text(row[20]), + rescale_factor=_decimal(row[21]), + tweakscale=_text(row[22]), + is_feeder=_boolean(row[23]), + tech_required=_text(row[24]), + note=_text(row[25]), + ) + ) + count += 1 + + return count + + +def _import_tank_specs(worksheet: object) -> int: + count = 0 + for row in worksheet.iter_rows(min_row=2, values_only=True): + if not _row_has_values(row): + continue + + tank_name = _text(row[0]) + if tank_name is None: + continue + + db.session.add( + TankSpec( + tank_name=tank_name, + fuel_type=_text(row[1]), + dry_mass_t=_decimal(row[2]), + fuel_mass_t=_decimal(row[3]), + wet_mass_t=_decimal(row[4]), + tank_volume_l=_decimal(row[5]), + mass_ratio=_decimal(row[6]), + kiloliters_per_ton=_decimal(row[7]), + vehicle_name=_text(row[8]), + source=_text(row[9]), + note=_text(row[10]), + ) + ) + count += 1 + + return count + + +def _import_vehicle_costs(worksheet: object) -> int: + count = 0 + for row in worksheet.iter_rows(min_row=2, values_only=True): + if not _row_has_values(row): + continue + + vehicle_name = _text(row[0]) + if vehicle_name is None: + continue + + db.session.add( + VehicleCost( + vehicle_name=vehicle_name, + launch_price=_integer(row[1]), + source=_text(row[2]), + ) + ) + count += 1 + + return count + + +def _row_has_values(row: tuple[object, ...]) -> bool: + return any(value not in (None, "") for value in row) + + +def _text(value: object) -> str | None: + if value in (None, ""): + return None + cleaned = str(value).strip() + return cleaned or None + + +def _decimal(value: object) -> Decimal | None: + if value in (None, ""): + return None + if isinstance(value, Decimal): + return value + try: + return Decimal(str(value)) + except (InvalidOperation, ValueError, TypeError): + return None + + +def _integer(value: object) -> int | None: + decimal_value = _decimal(value) + if decimal_value is None: + return None + return int(decimal_value.to_integral_value()) + + +def _boolean(value: object) -> bool | None: + if value in (None, ""): + return None + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + + text = str(value).strip().lower() + if text in {"true", "1", "yes", "y"}: + return True + if text in {"false", "0", "no", "n"}: + return False + return None + + +def _calculate_twr(max_thrust: Decimal | None, mass: Decimal | None) -> Decimal | None: + if max_thrust is None or mass in (None, Decimal("0")): + return None + return max_thrust / Decimal("9.80665") / mass + + +def _calculate_throttle_ratio( + min_thrust: Decimal | None, max_thrust: Decimal | None +) -> Decimal | None: + if min_thrust is None or max_thrust in (None, Decimal("0")): + return None + return min_thrust / max_thrust diff --git a/app/services/workbook_inspector.py b/app/services/workbook_inspector.py new file mode 100644 index 0000000..3325d72 --- /dev/null +++ b/app/services/workbook_inspector.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from collections import Counter, defaultdict +from pathlib import Path + +from openpyxl import load_workbook + + +ENGINE_FIELD_INDICES = { + "fuel_type": 1, + "cycle": 2, + "work_env": 3, + "tvc": 11, + "size": 12, + "price_kd": 13, + "entry_cost": 14, + "classification": 16, + "note": 17, + "part_name": 18, + "config_name": 19, +} + + +def summarize_workbook(workbook_path: str | Path) -> dict[str, object]: + path = Path(workbook_path) + workbook = load_workbook(path, data_only=False) + + sheet_row_counts = { + sheet_name: len(_non_empty_rows(workbook[sheet_name])) + for sheet_name in workbook.sheetnames + } + + engine_sheet = workbook["Engine Database"] + engine_rows = _non_empty_rows(engine_sheet) + engine_headers = [ + cell.value + for cell in next(engine_sheet.iter_rows(min_row=1, max_row=1)) + if cell.value is not None + ] + + duplicate_engine_config = _sample_duplicates( + Counter((row[0], row[19]) for row in engine_rows if _present(row[0])) + ) + duplicate_engine_config_with_fuel = _sample_duplicates( + Counter((row[0], row[19], row[1]) for row in engine_rows if _present(row[0])) + ) + + work_env_values = sorted( + {row[3] for row in engine_rows if _present(row[3])}, key=str + ) + + return { + "workbook": path.name, + "sheet_names": workbook.sheetnames, + "sheet_row_counts": sheet_row_counts, + "engine_headers": engine_headers, + "engine_work_env_values": work_env_values, + "candidate_key_conflicts": { + "engine_plus_config_name": { + "count": len(duplicate_engine_config), + "samples": duplicate_engine_config[:12], + }, + "engine_plus_config_name_plus_fuel_type": { + "count": len(duplicate_engine_config_with_fuel), + "samples": duplicate_engine_config_with_fuel[:12], + }, + }, + "engine_field_variance": _field_variance(engine_rows), + "recommended_family_fields": [ + "engine_name", + "part_name", + "cycle", + "size_m", + "entry_cost", + ], + "recommended_variant_fields": [ + "fuel_type", + "work_env", + "sl_isp", + "vac_isp", + "min_thrust_kn", + "max_thrust_kn", + "mass_t", + "twr", + "throttle_ratio", + "tvc_deg", + "price_kd", + "ignitions", + "has_unlimited_ignitions", + "mod_source", + "engine_note", + "config_name", + "tech_required", + "config_note", + ], + } + + +def _field_variance(engine_rows: list[tuple[object, ...]]) -> dict[str, int]: + grouped_rows: dict[object, list[tuple[object, ...]]] = defaultdict(list) + for row in engine_rows: + grouped_rows[row[0]].append(row) + + variance: dict[str, int] = {} + for field_name, index in ENGINE_FIELD_INDICES.items(): + changing = 0 + for rows in grouped_rows.values(): + values = {row[index] for row in rows if _present(row[index])} + if len(values) > 1: + changing += 1 + variance[field_name] = changing + return variance + + +def _sample_duplicates(counter: Counter[tuple[object, ...]]) -> list[dict[str, object]]: + samples: list[dict[str, object]] = [] + for key, count in counter.items(): + if count <= 1: + continue + + record = {"count": count} + for index, value in enumerate(key): + record[f"key_{index + 1}"] = value + samples.append(record) + + return samples + + +def _non_empty_rows(worksheet: object) -> list[tuple[object, ...]]: + return [ + row + for row in worksheet.iter_rows(min_row=2, values_only=True) + if any(_present(value) for value in row) + ] + + +def _present(value: object) -> bool: + return value not in (None, "") diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..3d4c065 --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,2815 @@ +:root { + color-scheme: light; + --bg: #e7f0fb; + --bg-accent: #bfd8ff; + --bg-top: #f3f8ff; + --bg-bottom: #e4eefb; + --bg-radial-1: rgba(23, 103, 213, 0.18); + --bg-radial-2: rgba(73, 156, 255, 0.16); + --surface: rgba(245, 250, 255, 0.84); + --surface-strong: rgba(255, 255, 255, 0.9); + --surface-soft: rgba(255, 255, 255, 0.72); + --surface-soft-strong: rgba(255, 255, 255, 0.78); + --surface-highlight: rgba(248, 251, 255, 0.96); + --surface-raised: rgba(255, 255, 255, 0.82); + --surface-hero-start: rgba(250, 253, 255, 0.95); + --surface-hero-end: rgba(224, 237, 255, 0.84); + --surface-panel-start: rgba(248, 251, 255, 0.88); + --surface-panel-end: rgba(241, 247, 255, 0.88); + --text: #12233e; + --muted: #5b6f8f; + --line: rgba(18, 35, 62, 0.12); + --primary: #1767d5; + --primary-strong: #0d4fa8; + --danger: #b63439; + --warning: #1b7cf2; + --shadow: 0 18px 48px rgba(18, 48, 96, 0.09); + --primary-soft: rgba(23, 103, 213, 0.08); + --primary-soft-strong: rgba(23, 103, 213, 0.14); + --primary-line: rgba(23, 103, 213, 0.16); + --primary-line-strong: rgba(23, 103, 213, 0.28); + --neutral-soft: rgba(91, 111, 143, 0.08); + --neutral-soft-strong: rgba(91, 111, 143, 0.16); + --danger-soft: rgba(182, 52, 57, 0.08); + --danger-line: rgba(182, 52, 57, 0.25); + --success-line: rgba(15, 107, 91, 0.28); + --warning-line: rgba(201, 105, 39, 0.28); + --timeline-grid: rgba(18, 35, 62, 0.08); + --timeline-grid-soft: rgba(18, 35, 62, 0.06); + --timeline-label-shadow: 14px 0 18px -18px rgba(18, 35, 62, 0.28); + --timeline-label-shadow-active: 14px 0 20px -18px rgba(18, 35, 62, 0.32), 0 0 0 3px rgba(23, 103, 213, 0.08); + --theme-toggle-bg: rgba(255, 255, 255, 0.78); + --theme-toggle-border: rgba(18, 35, 62, 0.12); + --theme-toggle-track: rgba(23, 103, 213, 0.16); + --theme-toggle-thumb: linear-gradient(135deg, #1767d5, #0d4fa8); + --tooltip-bg: rgba(18, 35, 62, 0.96); + --tooltip-text: #fff; + --radius-lg: 28px; + --radius-md: 18px; + --font-main: "Noto Sans SC", "Microsoft YaHei UI", "Segoe UI Variable", sans-serif; +} + +html[data-theme="dark"] { + color-scheme: dark; + --bg: #0d1522; + --bg-accent: #1d3153; + --bg-top: #111b2b; + --bg-bottom: #09111d; + --bg-radial-1: rgba(91, 149, 255, 0.2); + --bg-radial-2: rgba(49, 99, 186, 0.24); + --surface: rgba(17, 25, 39, 0.84); + --surface-strong: rgba(18, 28, 44, 0.92); + --surface-soft: rgba(18, 28, 44, 0.76); + --surface-soft-strong: rgba(21, 32, 49, 0.82); + --surface-highlight: rgba(24, 36, 54, 0.96); + --surface-raised: rgba(23, 34, 51, 0.88); + --surface-hero-start: rgba(22, 34, 52, 0.94); + --surface-hero-end: rgba(12, 19, 31, 0.92); + --surface-panel-start: rgba(24, 36, 54, 0.9); + --surface-panel-end: rgba(16, 25, 39, 0.92); + --text: #e8effc; + --muted: #9eafcb; + --line: rgba(160, 183, 219, 0.18); + --primary: #7eb5ff; + --primary-strong: #b2d0ff; + --danger: #ff9a96; + --warning: #8ac2ff; + --shadow: 0 22px 52px rgba(0, 0, 0, 0.34); + --primary-soft: rgba(96, 149, 255, 0.14); + --primary-soft-strong: rgba(96, 149, 255, 0.24); + --primary-line: rgba(132, 176, 255, 0.24); + --primary-line-strong: rgba(132, 176, 255, 0.34); + --neutral-soft: rgba(121, 140, 173, 0.14); + --neutral-soft-strong: rgba(121, 140, 173, 0.22); + --danger-soft: rgba(255, 124, 124, 0.1); + --danger-line: rgba(255, 154, 150, 0.24); + --success-line: rgba(71, 198, 176, 0.28); + --warning-line: rgba(255, 179, 102, 0.24); + --timeline-grid: rgba(165, 184, 214, 0.1); + --timeline-grid-soft: rgba(165, 184, 214, 0.08); + --timeline-label-shadow: 16px 0 22px -20px rgba(0, 0, 0, 0.5); + --timeline-label-shadow-active: 16px 0 22px -20px rgba(0, 0, 0, 0.54), 0 0 0 3px rgba(96, 149, 255, 0.14); + --theme-toggle-bg: rgba(18, 28, 44, 0.84); + --theme-toggle-border: rgba(160, 183, 219, 0.2); + --theme-toggle-track: rgba(96, 149, 255, 0.24); + --theme-toggle-thumb: linear-gradient(135deg, #9bc5ff, #6b9bff); + --tooltip-bg: rgba(7, 12, 21, 0.98); + --tooltip-text: #eef4ff; +} + +* { + box-sizing: border-box; +} + +html { + background: var(--bg-bottom); +} + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + font-family: var(--font-main); + background: + radial-gradient(circle at top left, var(--bg-radial-1), transparent 24%), + radial-gradient(circle at top right, var(--bg-radial-2), transparent 26%), + linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%); + transition: background 180ms ease, color 180ms ease; +} + +a, +button, +input, +select, +textarea { + transition: + background-color 180ms ease, + border-color 180ms ease, + color 180ms ease, + box-shadow 180ms ease, + transform 180ms ease; +} + +.page-shell { + width: calc(100vw - 16px); + max-width: none; + margin: 0 auto; + padding: 14px 0 36px; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 28px; +} + +.site-header-tools { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; + flex-wrap: wrap; +} + +.brand { + display: inline-flex; + flex-direction: column; + text-decoration: none; + color: var(--text); +} + +.brand-kicker { + font-size: 12px; + letter-spacing: 0.32em; + text-transform: uppercase; + color: var(--muted); +} + +.brand-name { + font-size: 28px; + font-weight: 700; +} + +.site-nav { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.site-nav a { + color: var(--text); + text-decoration: none; + padding: 9px 12px; + border-radius: 999px; + background: color-mix(in srgb, var(--surface-strong) 72%, transparent); + border: 1px solid var(--line); +} + +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid var(--theme-toggle-border); + background: var(--theme-toggle-bg); + color: var(--text); + box-shadow: var(--shadow); + cursor: pointer; +} + +.theme-toggle:hover, +.theme-toggle:focus-visible, +.site-nav a:hover, +.site-nav a:focus-visible { + border-color: var(--primary-line-strong); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 12%, transparent); +} + +.theme-toggle:focus-visible, +.site-nav a:focus-visible { + outline: none; +} + +.theme-toggle-track { + position: relative; + width: 42px; + height: 24px; + flex: 0 0 42px; + border-radius: 999px; + background: var(--theme-toggle-track); +} + +.theme-toggle-thumb { + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + border-radius: 999px; + background: var(--theme-toggle-thumb); + box-shadow: 0 6px 14px rgba(18, 35, 62, 0.24); + transition: transform 180ms ease, background 180ms ease, box-shadow 180ms ease; +} + +html[data-theme="dark"] .theme-toggle-thumb { + transform: translateX(18px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.36); +} + +.theme-toggle-copy { + display: grid; + gap: 1px; + text-align: left; +} + +.theme-toggle-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} + +.theme-toggle-value { + font-size: 13px; + font-weight: 700; +} + +.flash-stack { + display: grid; + gap: 12px; + margin-bottom: 20px; +} + +.flash-message { + padding: 14px 16px; + border-radius: 16px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-strong) 78%, transparent); +} + +.flash-message.success { + border-color: var(--success-line); +} + +.flash-message.error { + border-color: var(--warning-line); +} + +.hero-panel, +.panel, +.metric-card { + border: 1px solid var(--line); + box-shadow: var(--shadow); + backdrop-filter: blur(12px); +} + +.hero-panel { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; + padding: 28px; + border-radius: var(--radius-lg); + background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end)); +} + +.hero-panel.compact { + grid-template-columns: 1fr; +} + +.page-header-compact { + width: min(980px, 100%); + padding: 12px 14px; + gap: 10px; +} + +.page-header-compact .eyebrow { + margin-bottom: 4px; + font-size: 10px; +} + +.page-header-compact .hero-copy h1 { + font-size: clamp(22px, 2vw, 30px); + max-width: none; + line-height: 1; +} + +.page-header-compact .hero-text { + margin: 4px 0 0; + font-size: 13px; + line-height: 1.35; + max-width: 72ch; +} + +.engine-detail-hero { + width: min(720px, 100%); + padding: 8px 12px; + gap: 8px; +} + +.engine-detail-hero .hero-copy h1 { + font-size: clamp(20px, 1.9vw, 28px); + max-width: none; + line-height: 1; +} + +.engine-detail-hero .eyebrow { + margin-bottom: 3px; + font-size: 10px; +} + +.engine-detail-hero .hero-text { + margin: 4px 0 0; + font-size: 11px; + line-height: 1.2; + max-width: 58ch; +} + +.eyebrow { + margin: 0 0 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--primary); +} + +.hero-copy h1, +.panel-heading h2 { + margin: 0; + line-height: 1.1; +} + +.hero-copy h1 { + font-size: clamp(34px, 4vw, 56px); + max-width: 12ch; +} + +.hero-text, +.panel p, +.module-card p { + color: var(--muted); + line-height: 1.7; +} + +.hero-callout { + padding: 22px; + border-radius: var(--radius-md); + background: var(--primary-soft); + border: 1px solid var(--primary-line); +} + +.callout-label, +.callout-subtext { + margin: 0; + color: var(--muted); +} + +.callout-number { + margin: 12px 0 10px; + font-size: 72px; + font-weight: 700; + line-height: 1; + color: var(--warning); +} + +.callout-text { + margin: 0 0 10px; + font-weight: 600; +} + +.metric-grid, +.content-grid, +.decision-grid, +.factor-grid, +.engine-grid, +.detail-grid, +.summary-grid { + display: grid; + gap: 20px; +} + +.metric-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 22px 0; +} + +.metric-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px; + border-radius: var(--radius-md); + background: var(--surface); +} + +.metric-value { + font-size: 34px; + font-weight: 700; +} + +.metric-label { + color: var(--muted); +} + +.content-grid { + grid-template-columns: 1.4fr 1fr; + margin-bottom: 22px; +} + +.fuel-layout { + align-items: start; +} + +.panel { + padding: 18px; + border-radius: var(--radius-lg); + background: var(--surface); +} + +.catalog-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 20px; + align-items: start; +} + +.catalog-header-tight { + padding: 10px 14px; + gap: 12px; +} + +.catalog-page-header { + grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.95fr); + justify-content: stretch; + align-items: stretch; +} + +.catalog-header-copy { + min-width: 0; + display: grid; + align-content: center; +} + +.catalog-header-copy .eyebrow { + margin-bottom: 4px; +} + +.catalog-header-copy h1 { + margin: 0; + font-size: clamp(24px, 2.2vw, 32px); + line-height: 1; +} + +.catalog-header-copy .hero-text { + margin: 6px 0 0; + font-size: 14px; + line-height: 1.35; + max-width: 52ch; +} + +.engine-catalog-hero { + align-items: stretch; +} + +.catalog-header-side { + display: grid; + gap: 10px; + align-content: start; + justify-self: stretch; +} + +.catalog-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + align-self: start; + justify-self: stretch; + min-width: 0; + width: 100%; +} + +.catalog-metrics-duo { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.catalog-header-time-form { + display: grid; + grid-template-columns: minmax(240px, 1fr) auto; + gap: 10px; + align-items: end; +} + +.catalog-header-time-form .mission-datetime-control { + margin: 0; +} + +.catalog-header-time-form .secondary-button { + white-space: nowrap; +} + +.catalog-header-tight .summary-card { + padding: 10px 12px; + border-radius: 14px; + min-height: 74px; + display: grid; + align-content: space-between; +} + +.catalog-header-tight .summary-label { + margin-bottom: 4px; + font-size: 10px; +} + +.catalog-header-tight .summary-card.compact strong { + font-size: clamp(18px, 1.6vw, 28px); + line-height: 1.05; +} + +.catalog-sidebar { + display: grid; + gap: 12px; + align-content: start; +} + +.catalog-actions-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; +} + +.catalog-actions-panel .eyebrow { + margin-bottom: 2px; + font-size: 10px; +} + +.catalog-actions-panel h2 { + margin: 0; + font-size: 17px; + line-height: 1.1; +} + +.catalog-actions-panel .small-muted { + margin: 2px 0 0; +} + +.summary-card.compact strong { + font-size: 24px; +} + +.panel-wide { + min-width: 0; +} + +.top-gap { + margin-top: 18px; +} + +.panel-heading { + margin-bottom: 18px; +} + +.decision-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.decision-grid h3, +.module-card h3, +.factor-card h3 { + margin: 0 0 8px; +} + +.module-list { + display: grid; + gap: 14px; +} + +.module-card { + padding: 16px; + border-radius: 16px; + background: var(--surface-strong); + border: 1px solid var(--line); +} + +.module-card.live { + border-color: rgba(23, 103, 213, 0.28); +} + +.module-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.module-status { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--primary); +} + +.module-link { + display: inline-flex; + margin-top: 12px; + color: var(--primary-strong); + font-weight: 700; + text-decoration: none; +} + +.module-link.muted { + color: var(--muted); +} + +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 9px 12px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--surface-soft); + color: var(--text); + text-decoration: none; + font-weight: 600; +} + +.filter-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.catalog-layout { + display: grid; + grid-template-columns: clamp(220px, 15vw, 248px) minmax(0, 1fr); + gap: 14px; + margin-top: 14px; + align-items: start; +} + +.filter-panel { + position: sticky; + top: 8px; +} + +.filter-panel-tight { + padding: 14px; +} + +.filter-form { + display: grid; + gap: 10px; +} + +.filter-form input, +.filter-form select { + width: 100%; + padding: 9px 10px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--surface-soft-strong); + color: var(--text); + font: inherit; +} + +.catalog-panel { + padding: 12px 14px; +} + +.results-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.button-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.compact-row { + gap: 8px; +} + +.search-form { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 10px; + align-items: end; +} + +.filter-row { + grid-template-columns: minmax(260px, 2fr) repeat(3, minmax(140px, 1fr)) auto auto; +} + +.search-form input, +.search-form select, +.form-grid input, +.form-grid textarea, +.form-grid select { + width: 100%; + padding: 10px 11px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--surface-soft); + color: var(--text); + font: inherit; +} + +.form-grid textarea { + resize: vertical; +} + +.mission-datetime-field, +.mission-datetime-control { + display: grid; + gap: 6px; +} + +.mission-datetime-inputs, +.mission-datetime-control-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.mission-datetime-control-row { + align-items: stretch; + position: relative; +} + +.mission-datetime-control-input, +.mission-datetime-display-input { + flex: 1 1 220px; + min-width: 0; +} + +.mission-datetime-display-input { + width: 100%; +} + +.mission-datetime-picker-proxy { + position: absolute; + right: 0; + bottom: 0; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + border: 0 !important; + opacity: 0; + pointer-events: none; +} + +.mission-datetime-picker-button { + padding-inline: 12px; +} + +.mission-datetime-part { + flex: 0 0 62px; + text-align: center; +} + +.mission-datetime-year { + flex-basis: 84px; +} + +.mission-datetime-separator { + color: var(--muted); + font-size: 13px; + font-weight: 700; + line-height: 1; +} + +.combo-custom-input { + margin-top: 8px; +} + +.engine-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 22px; +} + +.engine-card h2 { + margin: 0; + font-size: 24px; +} + +.chip-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin: 12px 0; +} + +.chip { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + background: var(--primary-soft); + color: var(--primary-strong); + font-size: 13px; + font-weight: 600; +} + +.chip.strong { + background: var(--primary-soft-strong); + color: var(--primary-strong); +} + +.small-muted { + color: var(--muted); +} + +.preview-list { + display: grid; + gap: 10px; + margin: 16px 0 4px; +} + +.preview-row { + display: grid; + grid-template-columns: 1.5fr 1fr 1fr; + gap: 10px; + padding: 12px 14px; + border-radius: 14px; + background: var(--surface-soft); + border: 1px solid var(--line); +} + +.muted-row { + color: var(--muted); +} + +.empty-state { + text-align: center; +} + +.detail-grid { + grid-template-columns: 0.9fr 1.4fr; + margin-top: 22px; +} + +.summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.summary-card { + padding: 16px; + border-radius: 16px; + background: var(--surface-strong); + border: 1px solid var(--line); +} + +.summary-label { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 13px; +} + +.table-shell { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; +} + +.compact-table { + table-layout: fixed; +} + +.compact-table td, +.compact-table th { + padding: 7px 8px; +} + +.compact-table td { + font-size: 12.5px; + line-height: 1.3; +} + +.compact-table th { + font-size: 13px; +} + +.table-subline { + margin-top: 1px; + color: var(--muted); + font-size: 12px; +} + +.table-title { + display: block; + font-size: 13px; + line-height: 1.18; +} + +.two-line-cell { + min-width: 190px; + max-width: 240px; +} + +.single-line-cell { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.clamp-line { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + word-break: break-word; +} + +.column-sort-head { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + min-width: 0; + width: 100%; +} + +.numeric-head { + display: grid; + justify-items: end; + align-content: center; + gap: 2px; +} + +.sort-chip-group { + display: inline-flex; + gap: 4px; +} + +.sort-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 17px; + padding: 1px 4px; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.75); + color: var(--muted); + text-decoration: none; + font-size: 9px; + font-weight: 700; + line-height: 1.1; +} + +.sort-chip.active { + background: rgba(23, 103, 213, 0.14); + color: var(--primary-strong); + border-color: rgba(23, 103, 213, 0.28); +} + +.table-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.inline-form { + display: inline-flex; + margin: 0; +} + +.table-link { + color: var(--primary-strong); + font-weight: 700; + text-decoration: none; +} + +.danger-link { + border: 1px solid rgba(182, 52, 57, 0.25); + background: rgba(182, 52, 57, 0.08); + color: var(--danger); + border-radius: 999px; + padding: 6px 10px; + font: inherit; + font-weight: 700; + cursor: pointer; + text-decoration: none; +} + +.pagination-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-top: 18px; + flex-wrap: wrap; +} + +.pagination-pages { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.pagination-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + padding: 10px 14px; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.72); + color: var(--text); + text-decoration: none; + font-weight: 600; +} + +.pagination-link.current { + background: linear-gradient(135deg, var(--primary), var(--primary-strong)); + color: white; + border-color: transparent; +} + +.pagination-link.disabled { + color: var(--muted); + opacity: 0.6; +} + +.pagination-ellipsis { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + color: var(--muted); +} + +.compact-empty { + padding: 26px 8px 8px; +} + +.data-table th, +.data-table td { + padding: 7px 8px; + text-align: left; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} + +.data-table th { + color: var(--muted); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.engine-name-col { + width: 16%; +} + +.cycle-col { + width: 7%; +} + +.fuel-col { + width: 12%; +} + +.mod-col { + width: 9%; +} + +.count-col { + width: 5%; + text-align: center; +} + +.numeric-col { + width: 6%; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.action-col { + width: 15%; + text-align: right; +} + +.action-col .table-actions { + justify-content: flex-end; +} + +.action-col .table-actions, +.sort-chip-group { + white-space: nowrap; +} + +.variant-note { + margin-top: 6px; + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} + +.edit-layout { + display: grid; + gap: 14px; + margin-top: 14px; +} + +.form-grid { + display: grid; + gap: 14px; +} + +.family-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.variant-editor-stack { + display: grid; + gap: 16px; +} + +.variant-editor { + padding: 18px; + border-radius: 20px; + background: rgba(255, 255, 255, 0.62); + border: 1px solid var(--line); +} + +.variant-editor-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 14px; +} + +.variant-add-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.resource-form-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.field-span-three { + grid-column: span 3; +} + +.field-span-two { + grid-column: span 2; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 10px; +} + +.checkbox-row input { + width: auto; +} + +.converter-form { + display: grid; + gap: 12px; +} + +.field-label { + font-weight: 600; +} + +.field-hint { + display: block; + margin-top: 6px; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.converter-form input, +.converter-form select { + width: 100%; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.72); + color: var(--text); + font: inherit; +} + +.primary-button { + padding: 9px 13px; + border: 0; + border-radius: 999px; + background: linear-gradient(135deg, var(--primary), var(--primary-strong)); + color: white; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.primary-button, +.secondary-button, +.danger-link { + white-space: nowrap; +} + +.danger-panel { + border-color: rgba(182, 52, 57, 0.28); + background: rgba(255, 244, 245, 0.9); +} + +.form-error { + margin: 0; + color: var(--warning); + font-weight: 600; +} + +.result-summary { + margin-bottom: 14px; + color: var(--muted); +} + +.result-table { + display: grid; + gap: 10px; +} + +.result-row { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 12px; + align-items: center; + padding: 14px 16px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid var(--line); +} + +.factor-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.factor-card { + padding: 16px; + border-radius: 16px; + background: var(--surface-strong); + border: 1px solid var(--line); +} + +.mission-hero-side { + display: grid; + gap: 10px; +} + +.mission-time-callout { + padding: 18px; +} + +.mission-time-value { + margin: 8px 0 0; + font-size: 30px; + font-weight: 700; + line-height: 1; + color: var(--primary-strong); +} + +.mission-preview-nav { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 16px; +} + +.mission-preview-nav-top { + margin-top: 0; +} + +.mission-page-header { + display: grid; + grid-template-columns: minmax(220px, 0.78fr) minmax(0, 1.45fr) auto; + gap: 12px; + align-items: center; + padding: 12px 16px; +} + +.mission-page-header-copy { + min-width: 0; +} + +.mission-page-header-copy h1 { + margin: 0; + font-size: clamp(26px, 2.3vw, 36px); + line-height: 1.05; +} + +.mission-page-header-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + min-width: 0; +} + +.mission-page-header-controls { + min-width: 0; + justify-self: end; + width: min(100%, 380px); +} + +.mission-page-time-form { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + width: 100%; +} + +.mission-page-inline-datetime { + display: grid; + grid-template-columns: 64px minmax(160px, 1fr); + gap: 8px; + align-items: center; + margin: 0; + flex: 1 1 auto; +} + +.mission-page-inline-datetime .field-label { + margin: 0; + white-space: nowrap; + text-align: right; +} + +.mission-page-inline-datetime .mission-datetime-control-row { + min-width: 0; +} + +.mission-page-inline-datetime .mission-datetime-display-input { + flex: 1 1 160px; +} + +.mission-summary-card { + padding: 10px 12px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.76); + min-height: 62px; + display: grid; + align-content: space-between; +} + +.mission-summary-card strong { + font-size: clamp(18px, 1.45vw, 24px); + line-height: 1.05; +} + +.mission-summary-card-time strong { + font-size: clamp(15px, 0.95vw, 18px); + color: var(--primary-strong); + white-space: nowrap; +} + +.mission-ops-tabs-panel { + padding: 10px 12px; +} + +.preview-tab { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 12px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--surface-soft); + color: var(--text); + text-decoration: none; + font-weight: 600; + transition: background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease, color 180ms ease; +} + +.preview-tab:hover, +.preview-tab:focus-visible { + border-color: var(--primary-line); + background: color-mix(in srgb, var(--surface-soft) 84%, var(--primary-soft)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 10%, transparent); +} + +.preview-tab:focus-visible { + outline: none; +} + +.preview-tab.active { + background: linear-gradient(135deg, var(--primary), var(--primary-strong)); + color: white; + border-color: transparent; +} + +.preview-tab.active:hover, +.preview-tab.active:focus-visible { + border-color: transparent; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} + +.mission-metric-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.mission-page-time-form .secondary-button { + white-space: nowrap; +} + +.mission-control-panel { + padding: 12px 14px; +} + +.mission-toolbar-grid { + display: grid; + grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.25fr); + gap: 16px; + align-items: end; +} + +.mission-toolbar-block { + display: grid; + gap: 8px; +} + +.mission-toolbar-span-two { + grid-column: 1 / -1; +} + +.mission-toolbar-inline { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} + +.mission-control-panel input, +.mission-control-panel select { + width: 100%; + padding: 10px 11px; + border-radius: 10px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.72); + color: var(--text); + font: inherit; +} + +.mission-toolbar-inline input { + flex: 1 1 220px; +} + +.mission-toolbar-inline .mission-datetime-control { + min-width: min(280px, 100%); +} + +.mission-filter-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)) auto auto; + gap: 10px; + align-items: end; +} + +.mission-filter-pill-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 12px; +} + +.mission-filter-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(23, 103, 213, 0.08); + border: 1px solid rgba(23, 103, 213, 0.14); + color: var(--primary-strong); + font-size: 12px; + font-weight: 600; +} + +.mission-asset-picker-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.mission-asset-picker { + display: grid; + gap: 10px; +} + +.mission-asset-picker-toolbar { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto; + gap: 10px; + align-items: end; +} + +.mission-asset-picker-search { + display: grid; + gap: 6px; +} + +.mission-asset-picker-search input { + width: 100%; + padding: 10px 11px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--surface-soft); + color: var(--text); + font: inherit; +} + +.mission-asset-picker-search input::placeholder { + color: color-mix(in srgb, var(--muted) 72%, transparent); +} + +.mission-asset-picker-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; +} + +.mission-asset-picker-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-soft); + color: var(--text); + cursor: pointer; + transition: background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; +} + +.mission-asset-picker-item:hover { + border-color: var(--primary-line); + background: color-mix(in srgb, var(--surface-soft) 84%, var(--primary-soft)); +} + +.mission-asset-picker-item:has(input:checked) { + border-color: var(--primary-line-strong); + background: color-mix(in srgb, var(--surface-soft) 74%, var(--primary-soft-strong)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 12%, transparent); +} + +.mission-asset-picker-item input { + width: auto; + margin: 2px 0 0; + accent-color: var(--primary); +} + +.mission-asset-picker-item strong, +.mission-asset-picker-item small { + display: block; +} + +.mission-asset-picker-item strong { + color: var(--text); +} + +.mission-asset-picker-item small { + margin-top: 2px; + color: var(--muted); + font-size: 11px; +} + +.mission-board-shell, +.mission-detail-layout { + display: grid; + grid-template-columns: minmax(0, 1.45fr) 340px; + gap: 16px; + align-items: start; +} + +.mission-board-main, +.mission-board-side, +.mission-detail-main, +.mission-detail-side { + display: grid; + gap: 14px; +} + +.mission-group-panel, +.mission-log-panel, +.mission-asset-overview, +.mission-timeline-panel { + display: grid; + gap: 14px; +} + +.mission-log-filter-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)) auto auto; + gap: 10px; + align-items: end; +} + +.mission-log-filter-grid input { + width: 100%; + padding: 10px 11px; + border-radius: 10px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.72); + color: var(--text); + font: inherit; +} + +.mission-log-table td { + vertical-align: top; +} + +.mission-log-table .chip { + font-size: 11px; + padding: 5px 8px; +} + +.mission-entry-label-row { + gap: 6px; +} + +.mission-group-header, +.mission-card-top, +.mission-log-top, +.mission-asset-head, +.mission-timeline-toolbar { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + flex-wrap: wrap; +} + +.mission-group-header h2, +.mission-card-top h3, +.mission-asset-head h2, +.mission-upcoming-item h3, +.mission-log-body h3, +.mission-timeline-label h3 { + margin: 0; +} + +.mission-group-count { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid var(--line); + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.mission-group-subtitle, +.mission-type { + margin: 4px 0 0; +} + +.mission-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +html[data-theme="dark"] .page-shell { + background-image: + linear-gradient(to bottom, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0)); +} + +html[data-theme="dark"] .site-nav a, +html[data-theme="dark"] .secondary-button, +html[data-theme="dark"] .mission-datetime-picker-button, +html[data-theme="dark"] .mission-asset-picker-search input, +html[data-theme="dark"] .mission-asset-picker-item, +html[data-theme="dark"] .flash-message, +html[data-theme="dark"] .preview-row, +html[data-theme="dark"] .mission-asset-card, +html[data-theme="dark"] .mission-upcoming-item, +html[data-theme="dark"] .mission-log-body, +html[data-theme="dark"] .legend-item, +html[data-theme="dark"] .state-node-row, +html[data-theme="dark"] .mission-timeline-label, +html[data-theme="dark"] .catalog-sidebar, +html[data-theme="dark"] .catalog-panel, +html[data-theme="dark"] .filter-panel, +html[data-theme="dark"] .panel, +html[data-theme="dark"] .metric-card, +html[data-theme="dark"] .summary-card, +html[data-theme="dark"] .module-card { + background: var(--surface-soft); +} + +html[data-theme="dark"] .hero-panel { + background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end)); +} + +html[data-theme="dark"] .filter-form input, +html[data-theme="dark"] .filter-form select, +html[data-theme="dark"] .search-form input, +html[data-theme="dark"] .search-form select, +html[data-theme="dark"] input[type="search"], +html[data-theme="dark"] .form-grid input, +html[data-theme="dark"] .form-grid textarea, +html[data-theme="dark"] .form-grid select, +html[data-theme="dark"] input[type="datetime-local"], +html[data-theme="dark"] input[type="text"], +html[data-theme="dark"] input[type="number"], +html[data-theme="dark"] input[type="date"], +html[data-theme="dark"] input[type="time"], +html[data-theme="dark"] select, +html[data-theme="dark"] textarea { + background: var(--surface-soft-strong); + color: var(--text); + border-color: var(--line); +} + +html[data-theme="dark"] input::placeholder, +html[data-theme="dark"] textarea::placeholder { + color: color-mix(in srgb, var(--muted) 82%, transparent); +} + +html[data-theme="dark"] .data-table th, +html[data-theme="dark"] .data-table td, +html[data-theme="dark"] .catalog-table th, +html[data-theme="dark"] .catalog-table td { + border-color: var(--line); +} + +html[data-theme="dark"] .data-table thead th, +html[data-theme="dark"] .catalog-table thead th { + background: color-mix(in srgb, var(--surface-highlight) 80%, transparent); +} + +html[data-theme="dark"] .mission-tone-primary { + background: linear-gradient(180deg, rgba(52, 96, 171, 0.38), rgba(19, 31, 50, 0.9)); +} + +html[data-theme="dark"] .mission-tone-warning { + background: linear-gradient(180deg, rgba(39, 76, 140, 0.4), rgba(19, 31, 50, 0.9)); +} + +html[data-theme="dark"] .mission-tone-neutral { + background: linear-gradient(180deg, rgba(55, 66, 84, 0.38), rgba(19, 31, 50, 0.9)); +} + +html[data-theme="dark"] .mission-tone-soft { + background: rgba(20, 30, 46, 0.82); +} + +html[data-theme="dark"] .mission-status-pill, +html[data-theme="dark"] .chip, +html[data-theme="dark"] .mission-segment, +html[data-theme="dark"] .hero-callout { + background: var(--primary-soft); + border-color: var(--primary-line); +} + +html[data-theme="dark"] .chip.strong, +html[data-theme="dark"] .mission-tone-warning .mission-segment-body, +html[data-theme="dark"] .mission-tone-primary .mission-segment-body, +html[data-theme="dark"] .mission-tone-current.mission-segment { + background: var(--primary-soft-strong); +} + +html[data-theme="dark"] .mission-tone-primary.mission-segment, +html[data-theme="dark"] .mission-status-pill, +html[data-theme="dark"] .chip, +html[data-theme="dark"] .chip.strong, +html[data-theme="dark"] .mission-segment, +html[data-theme="dark"] .module-status, +html[data-theme="dark"] .mission-upcoming-date, +html[data-theme="dark"] .mission-log-kicker, +html[data-theme="dark"] .eyebrow { + color: var(--primary-strong); +} + +html[data-theme="dark"] .mission-log-rail::before { + background: color-mix(in srgb, var(--muted) 36%, transparent); +} + +html[data-theme="dark"] .mission-asset-picker-item:hover { + border-color: var(--primary-line-strong); + background: color-mix(in srgb, var(--surface-soft-strong) 78%, var(--primary-soft)); +} + +html[data-theme="dark"] .mission-asset-picker-item:has(input:checked) { + background: color-mix(in srgb, var(--surface-soft-strong) 70%, var(--primary-soft-strong)); + box-shadow: 0 0 0 3px rgba(96, 149, 255, 0.14); +} + +html[data-theme="dark"] .mission-log-dot { + border-color: rgba(12, 19, 31, 0.94); + box-shadow: 0 0 0 2px rgba(96, 149, 255, 0.14); +} + +html[data-theme="dark"] .mission-log-entry.state .mission-log-dot, +html[data-theme="dark"] .mission-event-node-state::before { + background: #8ea8d9; +} + +html[data-theme="dark"] .mission-timeline-corner, +html[data-theme="dark"] .state-node-panel { + background: linear-gradient(180deg, var(--surface-panel-start), var(--surface-panel-end)); +} + +html[data-theme="dark"] .mission-timeline-row { + border-bottom-color: color-mix(in srgb, var(--line) 70%, transparent); +} + +html[data-theme="dark"] .mission-timeline-label { + background: var(--surface-highlight); + box-shadow: var(--timeline-label-shadow); +} + +html[data-theme="dark"] .mission-timeline-label-link:hover, +html[data-theme="dark"] .mission-timeline-label-link:focus-visible { + border-color: var(--primary-line-strong); + box-shadow: var(--timeline-label-shadow-active); +} + +html[data-theme="dark"] .mission-timeline-lane { + background: + repeating-linear-gradient( + to right, + var(--timeline-grid) 0, + var(--timeline-grid) 1px, + transparent 1px, + transparent calc(100% / var(--timeline-columns, 1)) + ), + linear-gradient(180deg, rgba(20, 30, 47, 0.88), rgba(13, 21, 34, 0.92)); +} + +html[data-theme="dark"] .mission-timeline-state-lane { + background: + repeating-linear-gradient( + to right, + var(--timeline-grid) 0, + var(--timeline-grid) 1px, + transparent 1px, + transparent calc(100% / var(--timeline-columns, 1)) + ), + linear-gradient(180deg, rgba(21, 33, 51, 0.88), rgba(14, 22, 35, 0.92)); +} + +html[data-theme="dark"] .mission-timeline-event-lane { + background: + repeating-linear-gradient( + to right, + var(--timeline-grid-soft) 0, + var(--timeline-grid-soft) 1px, + transparent 1px, + transparent calc(100% / var(--timeline-columns, 1)) + ), + linear-gradient(180deg, rgba(25, 37, 57, 0.92), rgba(16, 24, 38, 0.94)); +} + +html[data-theme="dark"] .mission-segment-body { + background: var(--primary-soft-strong); + border-right-color: color-mix(in srgb, var(--primary) 36%, transparent); +} + +html[data-theme="dark"] .mission-tone-neutral.mission-segment, +html[data-theme="dark"] .mission-tone-soft.mission-segment { + background: var(--neutral-soft); + border-color: color-mix(in srgb, var(--muted) 30%, transparent); + color: #dbe6fa; +} + +html[data-theme="dark"] .mission-tone-neutral .mission-segment-body, +html[data-theme="dark"] .mission-tone-soft .mission-segment-body { + background: var(--neutral-soft-strong); +} + +html[data-theme="dark"] .mission-event-node::before { + box-shadow: 0 0 0 2px rgba(7, 12, 21, 0.98), 0 0 0 3px rgba(96, 149, 255, 0.1); +} + +html[data-theme="dark"] .mission-event-node-label, +html[data-theme="dark"] .legend-item, +html[data-theme="dark"] .table-subline, +html[data-theme="dark"] .mission-summary-text, +html[data-theme="dark"] .mission-card-foot, +html[data-theme="dark"] .hero-text, +html[data-theme="dark"] .panel p, +html[data-theme="dark"] .module-card p { + color: var(--muted); +} + +html[data-theme="dark"] .mission-state-node-connector { + border-left-color: color-mix(in srgb, var(--muted) 42%, transparent); +} + +html[data-theme="dark"] .mission-segment:hover::after, +html[data-theme="dark"] .mission-segment:focus-visible::after, +html[data-theme="dark"] .mission-event-node:hover::after, +html[data-theme="dark"] .mission-event-node:focus-visible::after { + background: var(--tooltip-bg); + color: var(--tooltip-text); + border: 1px solid color-mix(in srgb, var(--line) 90%, transparent); +} + +html[data-theme="dark"] .flash-message.error, +html[data-theme="dark"] .warning-box, +html[data-theme="dark"] .field-error { + background: var(--danger-soft); + border-color: var(--danger-line); +} + +html[data-theme="dark"] input:focus-visible, +html[data-theme="dark"] select:focus-visible, +html[data-theme="dark"] textarea:focus-visible, +html[data-theme="dark"] button:focus-visible, +html[data-theme="dark"] a:focus-visible { + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} + +@media (max-width: 1120px) { + .site-header { + flex-direction: column; + align-items: flex-start; + } + + .site-header-tools { + width: 100%; + justify-content: flex-start; + } +} + +@media (max-width: 760px) { + .site-header-tools { + gap: 10px; + } + + .theme-toggle { + width: 100%; + justify-content: center; + } +} + +.mission-asset-card { + display: grid; + gap: 12px; + padding: 16px; + border-radius: 20px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid var(--line); +} + +.mission-tone-primary { + background: linear-gradient(180deg, rgba(219, 235, 255, 0.86), rgba(255, 255, 255, 0.82)); +} + +.mission-tone-warning { + background: linear-gradient(180deg, rgba(231, 242, 255, 0.96), rgba(255, 255, 255, 0.82)); +} + +.mission-tone-neutral { + background: linear-gradient(180deg, rgba(245, 248, 252, 0.96), rgba(255, 255, 255, 0.84)); +} + +.mission-tone-soft { + background: rgba(255, 255, 255, 0.76); +} + +.mission-status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + border-radius: 999px; + background: rgba(23, 103, 213, 0.1); + border: 1px solid rgba(23, 103, 213, 0.15); + color: var(--primary-strong); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.mission-meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; +} + +.mission-meta-item { + margin: 0; +} + +.mission-meta-item dt { + margin: 0; + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.mission-meta-item dd { + margin: 5px 0 0; + font-weight: 600; + line-height: 1.45; +} + +.mission-summary-text { + margin: 0; + color: var(--muted); + line-height: 1.55; +} + +.mission-card-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + padding-top: 10px; + border-top: 1px dashed var(--line); + color: var(--muted); + font-size: 12px; +} + +.mission-upcoming-list, +.mission-action-stack { + display: grid; + gap: 10px; +} + +.mission-upcoming-item { + padding: 12px 13px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.74); + border: 1px solid var(--line); +} + +.mission-upcoming-date { + margin: 0; + color: var(--primary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; +} + +.mission-prototype-list { + display: grid; + gap: 10px; + margin: 0; + padding-left: 18px; + color: var(--muted); + line-height: 1.55; +} + +.mission-asset-chip-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.mission-log-list { + display: grid; + gap: 12px; +} + +.mission-log-entry { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 12px; +} + +.mission-log-rail { + position: relative; + display: flex; + justify-content: center; +} + +.mission-log-rail::before { + content: ""; + position: absolute; + top: 14px; + bottom: -14px; + width: 2px; + background: rgba(18, 35, 62, 0.1); +} + +.mission-log-dot { + position: relative; + z-index: 1; + width: 14px; + height: 14px; + margin-top: 6px; + border-radius: 999px; + background: var(--primary-strong); + border: 3px solid rgba(255, 255, 255, 0.92); + box-shadow: 0 0 0 2px rgba(23, 103, 213, 0.16); +} + +.mission-log-entry.state .mission-log-dot { + background: #3e6fb9; +} + +.mission-log-body { + display: grid; + gap: 8px; + padding: 14px 16px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.74); + border: 1px solid var(--line); +} + +.mission-log-kicker { + color: var(--primary); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.mission-log-time { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.mission-action-stack .primary-button, +.mission-action-stack .secondary-button { + width: 100%; +} + +.mission-legend { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.legend-item { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.8); + border: 1px solid var(--line); + color: var(--muted); + font-size: 12px; + font-weight: 600; +} + +.legend-swatch { + width: 12px; + height: 12px; + border-radius: 999px; + display: inline-block; +} + +.legend-swatch.warning { + background: rgba(23, 103, 213, 0.36); +} + +.legend-swatch.primary { + background: rgba(13, 79, 168, 0.42); +} + +.legend-swatch.neutral { + background: rgba(91, 111, 143, 0.36); +} + +.legend-swatch.current { + background: rgba(23, 103, 213, 0.78); + box-shadow: 0 0 0 2px rgba(23, 103, 213, 0.16); +} + +.mission-timeline-scroll { + overflow-x: auto; +} + +.mission-timeline-frame { + min-width: 1120px; +} + +.mission-timeline-scale { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 12px; + margin-bottom: 6px; +} + +.mission-timeline-corner { + position: sticky; + left: 0; + z-index: 5; + background: linear-gradient(180deg, rgba(245, 249, 255, 0.98), rgba(240, 246, 255, 0.98)); + border-radius: 16px; +} + +.mission-scale-labels { + display: grid; + grid-template-columns: repeat(var(--timeline-columns, 8), minmax(var(--timeline-column-width, 72px), 1fr)); + gap: 8px; +} + +.mission-scale-labels span { + padding: 0 0 6px; + color: var(--muted); + font-size: 12px; + font-weight: 700; + text-align: center; +} + +.mission-timeline-rows { + display: grid; + gap: 10px; +} + +.mission-timeline-row { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 12px; + align-items: start; + padding-bottom: 10px; + border-bottom: 1px solid rgba(18, 35, 62, 0.08); +} + +.mission-timeline-row:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.mission-timeline-label { + position: sticky; + left: 0; + z-index: 4; + display: block; + padding: 12px 14px; + border-radius: 18px; + background: rgba(248, 251, 255, 0.96); + border: 1px solid var(--line); + box-shadow: 14px 0 18px -18px rgba(18, 35, 62, 0.28); +} + +.mission-timeline-label-link { + color: inherit; + text-decoration: none; + transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease; +} + +.mission-timeline-label-link:hover, +.mission-timeline-label-link:focus-visible { + border-color: rgba(23, 103, 213, 0.24); + box-shadow: 14px 0 20px -18px rgba(18, 35, 62, 0.32), 0 0 0 3px rgba(23, 103, 213, 0.08); +} + +.mission-timeline-label-link:focus-visible { + outline: none; +} + +.mission-timeline-label p { + margin: 4px 0 0; +} + +.mission-timeline-meta, +.mission-timeline-status { + font-size: 11px; + line-height: 1.35; +} + +.mission-timeline-status { + margin-top: 2px; +} + +.state-node-panel { + display: grid; + gap: 14px; + padding: 16px 18px; + border-radius: 18px; + border: 1px solid var(--line); + background: linear-gradient(180deg, rgba(248, 251, 255, 0.88), rgba(241, 247, 255, 0.88)); +} + +.state-node-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.state-node-panel-header .field-hint, +.state-node-empty { + margin: 6px 0 0; +} + +.state-node-list { + display: grid; + gap: 12px; +} + +.state-node-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 0.95fr) minmax(0, 1.2fr) auto; + gap: 12px; + align-items: end; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(18, 35, 62, 0.08); + background: rgba(255, 255, 255, 0.82); +} + +.state-node-row label, +.state-node-row .mission-datetime-control { + margin: 0; +} + +.state-node-detail-field textarea { + min-height: 72px; + resize: vertical; +} + +.state-node-datetime { + min-width: 0; +} + +.state-node-remove-button { + align-self: end; +} + +.mission-timeline-track-stack { + display: grid; + gap: 8px; +} + +.mission-timeline-lane { + position: relative; + min-height: calc(var(--timeline-track-count, 1) * var(--timeline-track-step, 38px) + var(--timeline-track-base, 24px)); + border-radius: 18px; + background: + repeating-linear-gradient( + to right, + rgba(18, 35, 62, 0.08) 0, + rgba(18, 35, 62, 0.08) 1px, + transparent 1px, + transparent calc(100% / var(--timeline-columns, 1)) + ), + linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(238, 245, 255, 0.72)); + border: 1px solid var(--line); + overflow: visible; +} + +.mission-timeline-state-lane { + --timeline-track-step: 38px; + --timeline-track-base: 22px; + background: + repeating-linear-gradient( + to right, + rgba(18, 35, 62, 0.08) 0, + rgba(18, 35, 62, 0.08) 1px, + transparent 1px, + transparent calc(100% / var(--timeline-columns, 1)) + ), + linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(237, 244, 255, 0.8)); +} + +.mission-timeline-event-lane { + --timeline-track-step: 54px; + --timeline-track-base: 18px; + background: + repeating-linear-gradient( + to right, + rgba(18, 35, 62, 0.06) 0, + rgba(18, 35, 62, 0.06) 1px, + transparent 1px, + transparent calc(100% / var(--timeline-columns, 1)) + ), + linear-gradient(180deg, rgba(251, 253, 255, 0.92), rgba(244, 248, 255, 0.88)); +} + +.mission-timeline-lane-year { + border-radius: 20px; +} + +.mission-segment { + position: absolute; + top: calc(10px + var(--timeline-track-index, 0) * 38px); + z-index: 1; + min-height: 30px; + padding: 0 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + color: var(--primary-strong); + background: rgba(23, 103, 213, 0.08); + border: 1px solid rgba(23, 103, 213, 0.16); + cursor: pointer; + text-decoration: none; + overflow: hidden; +} + +.mission-timeline-state-lane .mission-segment { + top: calc(10px + var(--timeline-track-index, 0) * var(--timeline-track-step, 38px)); +} + +.mission-segment-compact { + padding: 0 7px; +} + +.mission-segment-compact .mission-segment-text { + font-size: 10px; +} + +.mission-segment-body { + position: absolute; + inset: 0 auto 0 0; + min-width: 8px; + border-radius: 999px; + background: rgba(23, 103, 213, 0.16); + border-right: 1px solid rgba(23, 103, 213, 0.12); +} + +.mission-segment-text { + position: relative; + z-index: 1; + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mission-tone-warning.mission-segment { + background: rgba(23, 103, 213, 0.09); +} + +.mission-tone-warning .mission-segment-body { + background: rgba(23, 103, 213, 0.16); +} + +.mission-tone-primary.mission-segment { + background: rgba(13, 79, 168, 0.1); + color: #0a3b7d; +} + +.mission-tone-primary .mission-segment-body { + background: rgba(13, 79, 168, 0.2); +} + +.mission-tone-neutral.mission-segment, +.mission-tone-soft.mission-segment { + background: rgba(91, 111, 143, 0.08); + border-color: rgba(91, 111, 143, 0.18); + color: #42546d; +} + +.mission-tone-neutral .mission-segment-body, +.mission-tone-soft .mission-segment-body { + background: rgba(91, 111, 143, 0.16); +} + +.mission-tone-current.mission-segment { + box-shadow: 0 0 0 2px rgba(23, 103, 213, 0.18); +} + +.mission-event-node { + display: flex; + align-items: flex-end; + justify-content: flex-start; + width: 0; + min-width: 0; + min-height: 48px; + cursor: pointer; + text-decoration: none; + position: absolute; + top: calc(6px + var(--timeline-track-index, 0) * var(--timeline-track-step, 54px)); + z-index: 2; + white-space: nowrap; + overflow: visible; +} + +.mission-event-node::before { + content: ""; + position: absolute; + left: -5px; + top: 12px; + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--primary-strong); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.96), 0 0 0 4px rgba(23, 103, 213, 0.14); + z-index: 1; +} + +.mission-event-node-label { + position: absolute; + left: 10px; + bottom: 0; + display: block; + width: var(--event-label-width, 110px); + color: var(--muted); + font-size: 11px; + line-height: 1.35; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + z-index: 1; +} + +.mission-event-node-label-left .mission-event-node-label { + left: auto; + right: 10px; + text-align: right; +} + +.mission-event-node-state::before { + background: #5b6f8f; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.96), 0 0 0 4px rgba(91, 111, 143, 0.16); +} + +.mission-state-node-connector { + position: absolute; + left: 0; + top: calc(17px - var(--state-node-connector-height, 0px)); + height: var(--state-node-connector-height, 0px); + border-left: 2px dashed rgba(91, 111, 143, 0.6); + pointer-events: none; + z-index: 0; +} + +.mission-segment:hover, +.mission-event-node:hover { + z-index: 3; +} + +.mission-segment:hover::after, +.mission-segment:focus-visible::after, +.mission-event-node:hover::after, +.mission-event-node:focus-visible::after { + content: attr(data-full-label); + position: absolute; + top: calc(100% + 8px); + left: 0; + max-width: min(320px, 42vw); + padding: 8px 10px; + border-radius: 10px; + background: rgba(18, 35, 62, 0.96); + color: #fff; + font-size: 12px; + font-weight: 600; + line-height: 1.45; + white-space: normal; + box-shadow: var(--shadow); + pointer-events: none; + z-index: 5; +} + +.mission-event-node:hover::after, +.mission-event-node:focus-visible::after { + top: auto; + bottom: calc(100% + 10px); + left: 50%; + transform: translateX(-50%); +} + +@media (max-width: 1180px) { + .mission-hero, + .mission-toolbar-grid, + .mission-board-shell, + .mission-detail-layout, + .mission-card-grid { + grid-template-columns: 1fr; + } + + .mission-filter-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mission-asset-picker-toolbar { + grid-template-columns: 1fr; + } + + .mission-asset-picker-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mission-log-filter-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .state-node-row { + grid-template-columns: 1fr; + } + + .mission-hero-side, + .mission-board-side, + .mission-detail-side { + position: static; + } +} + +@media (max-width: 820px) { + .mission-page-header { + grid-template-columns: minmax(220px, 0.75fr) minmax(0, 1fr); + } + + .state-node-panel-header { + flex-direction: column; + } + + .state-node-remove-button { + width: 100%; + } + + .mission-page-header-copy { + grid-column: 1; + grid-row: 1 / span 2; + } + + .mission-page-header-metrics, + .mission-page-header-controls { + grid-column: 2; + } + + .mission-page-header-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1120px) { + .mission-metric-grid, + .mission-meta-grid, + .mission-filter-grid, + .mission-asset-picker-grid, + .mission-card-grid, + .mission-timeline-row, + .mission-timeline-scale { + grid-template-columns: 1fr; + } + + .mission-toolbar-inline, + .mission-card-foot { + flex-direction: column; + align-items: flex-start; + } + + .mission-page-header { + grid-template-columns: 1fr; + } + + .mission-page-header-copy, + .mission-page-header-metrics, + .mission-page-header-controls { + grid-column: auto; + grid-row: auto; + } + + .mission-page-time-form { + flex-wrap: wrap; + justify-content: flex-start; + } + + .mission-page-inline-datetime { + grid-template-columns: 1fr; + } + + .mission-event-node span { + position: static; + transform: none; + width: auto; + margin-top: 24px; + } + + .mission-timeline-label, + .mission-timeline-corner { + position: static; + } +} + +@media (max-width: 960px) { + .hero-panel, + .catalog-layout, + .content-grid, + .metric-grid, + .decision-grid, + .factor-grid, + .engine-grid, + .detail-grid, + .summary-grid, + .family-grid, + .variant-editor-grid, + .variant-add-grid, + .resource-form-grid { + grid-template-columns: 1fr; + } + + .search-form, + .filter-row { + grid-template-columns: 1fr; + } + + .filter-panel { + position: static; + } + + .results-header, + .pagination-bar { + flex-direction: column; + align-items: flex-start; + } + + .catalog-header, + .catalog-page-header { + grid-template-columns: 1fr; + } + + .catalog-metrics { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + width: 100%; + } + + .catalog-header-time-form { + grid-template-columns: minmax(0, 1fr) auto; + } + + .catalog-actions-panel { + flex-direction: column; + align-items: flex-start; + } + + .preview-row { + grid-template-columns: 1fr; + } + + .field-span-two { + grid-column: span 1; + } +} + +@media (max-width: 720px) { + .catalog-header { + grid-template-columns: 1fr; + } + + .catalog-metrics, + .catalog-metrics-duo, + .catalog-header-time-form { + grid-template-columns: 1fr; + width: 100%; + } + + .field-span-three { + grid-column: span 1; + } + + .site-header { + flex-direction: column; + align-items: flex-start; + } + + .hero-copy h1 { + max-width: none; + } +} diff --git a/app/templates/_datetime_picker_field.html b/app/templates/_datetime_picker_field.html new file mode 100644 index 0000000..0e03d15 --- /dev/null +++ b/app/templates/_datetime_picker_field.html @@ -0,0 +1,12 @@ +{% macro datetime_picker_field(label, name, value='', field_id=None, required=False, wrapper_class='', wrapper_id=None, label_id=None, button_label='选择') %} +{% set display_value = value|replace('-', '/')|replace('T', ' ') if value else '' %} +{% set picker_id = (field_id or name) ~ '-picker' %} +

+ + + +
+ +{% endmacro %} \ No newline at end of file diff --git a/app/templates/_mission_ops_tabs.html b/app/templates/_mission_ops_tabs.html new file mode 100644 index 0000000..af5c609 --- /dev/null +++ b/app/templates/_mission_ops_tabs.html @@ -0,0 +1,9 @@ +
+ +
\ No newline at end of file diff --git a/app/templates/_mission_preview_controls.html b/app/templates/_mission_preview_controls.html new file mode 100644 index 0000000..7590cf9 --- /dev/null +++ b/app/templates/_mission_preview_controls.html @@ -0,0 +1,40 @@ +
+
+
+ Time Controls +
+ + + + +
+
+ +
+ Board Filters +
+ + + + + +
+
+
+ +
\ No newline at end of file diff --git a/app/templates/_mission_preview_header.html b/app/templates/_mission_preview_header.html new file mode 100644 index 0000000..048e4cb --- /dev/null +++ b/app/templates/_mission_preview_header.html @@ -0,0 +1,40 @@ +{% from "_datetime_picker_field.html" import datetime_picker_field %} +{% set header_metrics = metrics if metrics is defined else [] %} +{% set header_eyebrow = preview_eyebrow if preview_eyebrow is defined else 'Mission Ops' %} +{% set time_action = header_time_action if header_time_action is defined else None %} +{% set time_submit_label = header_time_submit_label if header_time_submit_label is defined else 'Apply Time' %} +{% set time_field_id = header_time_field_id if header_time_field_id is defined else 'page-header-sim-time' %} +{% set hidden_fields = header_hidden_fields if header_hidden_fields is defined else [] %} + +
+
+

{{ header_eyebrow }}

+

{{ preview_title }}

+
+ +
+ {% for metric in header_metrics %} +
+ {{ metric.label }} + {{ metric.value }} +
+ {% endfor %} + +
+ Current Simulation Time + {{ simulation_time_display or simulation_time }} +
+
+ +
+ {% if time_action and simulation_time_input %} +
+ {% for field in hidden_fields %} + + {% endfor %} + {{ datetime_picker_field('Edit Time', 'sim_time', simulation_time_input, field_id=time_field_id, wrapper_class='mission-page-inline-datetime') }} + +
+ {% endif %} +
+
\ No newline at end of file diff --git a/app/templates/_mission_upcoming_events.html b/app/templates/_mission_upcoming_events.html new file mode 100644 index 0000000..632c872 --- /dev/null +++ b/app/templates/_mission_upcoming_events.html @@ -0,0 +1,23 @@ + \ No newline at end of file diff --git a/app/templates/asset_detail.html b/app/templates/asset_detail.html new file mode 100644 index 0000000..b23e1d0 --- /dev/null +++ b/app/templates/asset_detail.html @@ -0,0 +1,185 @@ +{% extends "base.html" %} +{% from "_datetime_picker_field.html" import datetime_picker_field %} + +{% block content %} +{% include "_mission_ops_tabs.html" %} +{% include "_mission_preview_header.html" %} + +
+
+
+
+
+

Asset Summary

+

{{ asset.name }}

+

{{ asset.asset_type }} · {{ asset.program or 'No program assigned' }}

+
+
+ {{ asset_summary.current_state }} + {{ asset_summary.current_location }} + {% if asset.home_region %} + {{ asset.home_region }} + {% endif %} +
+
+ +
+
+
Current Mission
+
{{ asset_summary.current_mission }}
+
+
+
Active Window
+
{{ asset_summary.state_window }}
+
+
+
Next Planned Event
+
{{ asset_summary.next_event }}
+
+
+
+ +
+
+
+

历史记录

+

资产日志

+
+
{{ filtered_entry_count }} / {{ total_entry_count }} entries
+
+ +
+ + {{ datetime_picker_field('Start', 'start', entry_filter_start_input, field_id='asset-log-start') }} + {{ datetime_picker_field('End', 'end', entry_filter_end_input, field_id='asset-log-end') }} + + 重置 +
+ + {% if entries %} +
+ + + + + + + + + + + {% for entry in entries %} + + + + + + + {% endfor %} + +
TimeEntryLabelsAction
+ {{ entry.timestamp }} + {% if entry.end_timestamp %} +
→ {{ entry.end_timestamp }}
+ {% endif %} +
{{ entry.location }}
+
+ {{ entry.title }} +
{{ entry.mode_label }} · {{ entry.status_label }}
+
{{ entry.summary }}
+
+ {% if entry.state_labels or entry.mission_labels %} + + {% else %} + - + {% endif %} + + +
+
+ + {% if entry_total_pages > 1 %} + + {% endif %} + {% else %} +
+ {% if total_entry_count %} +

没有命中筛选条件的日志条目

+

调整起止时间,或者重置筛选后重试。

+ {% else %} +

还没有日志条目

+

先为这个资产新增一个 Event Point 或 State Interval。

+ {% endif %} +
+ {% endif %} +
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/asset_entry_form.html b/app/templates/asset_entry_form.html new file mode 100644 index 0000000..64aadac --- /dev/null +++ b/app/templates/asset_entry_form.html @@ -0,0 +1,264 @@ +{% extends "base.html" %} +{% from "_datetime_picker_field.html" import datetime_picker_field %} + +{% block content %} +{% include "_mission_ops_tabs.html" %} +{% macro render_end_at_field(end_at_value) %} +{{ datetime_picker_field('End At', 'end_at', end_at_value, field_id='entry-end-at-input', wrapper_class='field-span-two mission-datetime-field', wrapper_id='entry-end-at-field') }} +{% endmacro %} +{% macro render_state_node_row(node, row_index) %} +
+ + + + + {{ datetime_picker_field('Node At', 'state_node_at', node.at if node else '', field_id='state-node-at-' ~ row_index, wrapper_class='state-node-datetime') }} + + + + +
+{% endmacro %} + +
+
+

资产日志

+

{{ heading }}

+
+
+ +
+ +
+ {% set is_event_kind = form_values.entry_kind == 'event' %} +
+ + + + + + + + + + + + + {{ datetime_picker_field('At' if is_event_kind else 'Start At', 'start_at', form_values.start_at, field_id='entry-start-at-input', required=True, wrapper_class='field-span-two mission-datetime-field', wrapper_id='entry-start-at-field', label_id='entry-start-at-label') }} + + {% if not is_event_kind %} + {{ render_end_at_field(form_values.end_at) }} + {% endif %} + +
+
+
+ State Nodes +

每个 State Node 包含时间、标题和 Node Detail,并会在 Timeline 的 Event Point 泳道中显示。

+
+ +
+ +
+ {% for node in form_values.state_nodes %} + {{ render_state_node_row(node, loop.index0) }} + {% endfor %} +
+ +

当前没有 State Node,可按需添加。

+
+ + + + +
+ +
+ + 返回 +
+
+
+ +{% if delete_url %} +
+

删除后不可恢复,请谨慎操作。

+
+ + +
+
+{% endif %} + + + + + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/asset_list.html b/app/templates/asset_list.html new file mode 100644 index 0000000..6d9a268 --- /dev/null +++ b/app/templates/asset_list.html @@ -0,0 +1,114 @@ +{% extends "base.html" %} +{% from "_datetime_picker_field.html" import datetime_picker_field %} + +{% block content %} +{% include "_mission_ops_tabs.html" %} +{% include "_mission_preview_header.html" %} + +
+
+

Actions

+

新增内容

+
+
+ 新增任务资产 +
+ + +
+
+
+ +
+
+ + + + + + + + 重置 +
+ + {% if rows %} +
+ + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + {% endfor %} + +
AssetTypeProgramCurrent EventNext EventEntriesAction
+ {{ row.asset.name }} + {{ row.asset.asset_type }}{{ row.asset.program or '-' }}{{ row.current_event }}{{ row.next_event }}{{ row.entry_count }} + +
+
+ + {% if total_pages > 1 %} + + {% endif %} + {% else %} +
+

没有命中结果

+

调整筛选条件或重置后重试。

+
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..af645e9 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,284 @@ + + + + + + + {{ title }} · {{ config["PROJECT_TITLE"] }} + + + + +
+ + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+
+ + + + diff --git a/app/templates/communication_list.html b/app/templates/communication_list.html new file mode 100644 index 0000000..8e59932 --- /dev/null +++ b/app/templates/communication_list.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Communication

+

通信部件目录

+

支持分页、筛选、排序和快速编辑。

+
+
+
+
+ 总记录 + {{ total_catalog_count }} +
+
+ 当前结果 + {{ total_count }} +
+
+ 当前页 + {{ page }} / {{ total_pages }} +
+
+
+
+ +
+
+

Actions

+

新增内容

+
+ 新增通信部件 +
+ +
+
+ + + + + + + + + + 重置 +
+ + {% if rows %} +
+ + + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + + + {% endfor %} + +
PartDisplayTypeRange (km)Mass (t)Idle (W)SourceFlagsAction
{{ row.part_name }}{{ row.display_name or '-' }}{{ row.antenna_type or '-' }}{{ row.range_km if row.range_km is not none else '-' }}{{ row.mass_t if row.mass_t is not none else '-' }}{{ row.idle_power_watt if row.idle_power_watt is not none else '-' }}{{ row.source or '-' }} + {% if row.is_active %}Active{% else %}-{% endif %} + {% if row.is_deployable %} / Deployable{% endif %} + {% if row.is_feeder %} / Feeder{% endif %} + +
+ 编辑 +
+ +
+
+
+
+ + {% if total_pages > 1 %} + + {% endif %} + {% else %} +
+

没有命中结果

+

调整筛选条件或重置后重试。

+
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..8e7641a --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

项目起步版本

+

先把数据模型和导入边界定死,再做 CRUD。

+

+ 这个骨架现在已经提供 Flask 应用、规范化数据库模型、Docker 运行基线、Excel 巡检脚本, + 以及第一块可直接使用的 Fuel Chart 页面工具。 +

+
+
+

关键风险

+

{{ duplicate_conflicts }}

+

组 Engine + Config Name 在原始工作簿中存在重复,不能作为数据库硬唯一键。

+

即便补上 Fuel Type,仍然还有 {{ duplicate_conflicts_with_fuel }} 组冲突。

+
+
+ +
+ {% for metric in metrics %} +
+ {{ metric.value }} + {{ metric.label }} +
+ {% endfor %} +
+ +
+
+
+

建模决策

+

当前推荐的数据库拆分

+
+
+
+

engine_families

+

只放经 Excel 校验后稳定的字段:Engine、Name、Cycle、Size、entry Cost。

+
+
+

engine_variants

+

放所有配置级字段:Fuel Type、Work Env、ISP、推力、TVC、价格、点火次数、Tech、备注。

+
+
+

代理主键

+

所有核心表使用 UUID 主键,导入时保留原始自然键,并单独输出重复告警。

+
+
+

Fuel Chart

+

不先入库,先作为常量配置和 API 小工具,后面再看是否要抽成字典表。

+
+
+
+ +
+
+

模块状态

+

第一批交付

+
+
+ {% for module in modules %} +
+
+ {{ module.status }} +

{{ module.title }}

+
+

{{ module.description }}

+ {% if module.endpoint %} + 立即打开 + {% else %} + 下一阶段实现 + {% endif %} +
+ {% endfor %} +
+
+
+{% endblock %} + diff --git a/app/templates/engine_create.html b/app/templates/engine_create.html new file mode 100644 index 0000000..1b060cb --- /dev/null +++ b/app/templates/engine_create.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Engine Create

+

新增引擎

+

创建一个新的引擎家族,并同时录入第一条配置项。

+
+
+ +
+
+
+

Family

+

基础字段

+
+
+ {% for field in family_fields %} + {% set field_value = request.form.get(field.name, '') %} + + {% endfor %} +
+
+ +
+
+

Initial Variant

+

首个配置项

+
+
+ {% for field in variant_fields %} + {% set input_name = 'initial-' ~ field.name %} + {% set field_value = request.form.get(input_name, '') %} + + {% endfor %} +
+ +
+ + 取消 +
+
+
+{% endblock %} diff --git a/app/templates/engine_detail.html b/app/templates/engine_detail.html new file mode 100644 index 0000000..7d554d9 --- /dev/null +++ b/app/templates/engine_detail.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Engine Detail

+

{{ family.engine_name }}

+

查看家族级字段,以及该引擎下全部配置项的关键性能参数。

+
+
+ +
+
+
+

Family

+

基础信息

+
+
+
+ Part Name + {{ family.part_name or "-" }} +
+
+ Cycle + {{ family.cycle or "-" }} +
+
+ Size + {{ family.size_m|fmt_decimal }} +
+
+ Entry Cost + {{ family.entry_cost if family.entry_cost is not none else "-" }} +
+
+
+ 编辑这个引擎 + 返回列表 +
+ +
+
+
+ +
+
+

Variants

+

配置列表

+
+
+ + + + + + + + + + + + + + + + + + + + {% for row in variant_rows %} + {% set variant = row.variant %} + + + + + + + + + + + + + + + + {% endfor %} + +
ConfigFuelEnvSL ThrustVac ThrustSL ISPVac ISPMassTWRPriceIgnitionsModTech
+ {{ variant.config_name or "Default" }} + {% if variant.config_note %} +
{{ variant.config_note }}
+ {% endif %} +
{{ variant.fuel_type }}{{ variant.work_env or "-" }}{{ row.sl_thrust_kn|fmt_decimal }}{{ row.vac_thrust_kn|fmt_decimal }}{{ variant.sl_isp|fmt_decimal }}{{ variant.vac_isp|fmt_decimal }}{{ variant.mass_t|fmt_decimal }}{{ variant.twr|fmt_decimal }}{{ variant.price_kd if variant.price_kd is not none else "-" }} + {% if variant.has_unlimited_ignitions %} + Unlimited + {% else %} + {{ variant.ignitions if variant.ignitions is not none else "-" }} + {% endif %} + {{ variant.mod_source or "-" }}{{ variant.tech_required or "-" }}
+
+
+
+{% endblock %} diff --git a/app/templates/engine_edit.html b/app/templates/engine_edit.html new file mode 100644 index 0000000..e8db97e --- /dev/null +++ b/app/templates/engine_edit.html @@ -0,0 +1,247 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Engine Edit

+

编辑 {{ family.engine_name }}

+

支持家族字段编辑、配置项新增、配置项删除,以及整机删除。

+
+
+ +
+
+

Add Variant

+

新增配置项

+
+
+ {% for field in variant_create_fields %} + {% set input_name = 'add-' ~ field.name %} + + {% endfor %} +
+ +
+
+
+ +
+
+
+

Family

+

基础字段

+
+
+ {% for field in family_fields %} + {% set current_value = family|attr(field.name) if family|attr(field.name) is not none else '' %} + + {% endfor %} +
+
+ +
+
+

Variants

+

配置字段

+
+ +
+ {% for variant in family.variants %} +
+
+

{{ variant.config_name or "Default" }}

+
+ Row {{ variant.source_sheet_row or "-" }} + +
+
+ +
+ {% for field in variant_fields %} + {% set current_value = variant|attr(field.name) %} + {% set input_name = 'variant-' ~ variant.id ~ '-' ~ field.name %} + {% if field.kind == 'textarea' %} + + {% elif field.kind == 'checkbox' %} + + {% elif field.kind == 'combo' %} + + {% else %} + + {% endif %} + {% endfor %} +
+
+ {% endfor %} +
+ +
+ + 取消 + +
+
+
+ + +{% endblock %} diff --git a/app/templates/engine_list.html b/app/templates/engine_list.html new file mode 100644 index 0000000..f39d2e9 --- /dev/null +++ b/app/templates/engine_list.html @@ -0,0 +1,262 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Engine Catalog

+

引擎目录

+

宽屏高密度视图,支持分页、燃料/循环/Mod 筛选,并按推力或比冲排序。

+
+
+
+
+ 总引擎家族 + {{ total_catalog_count }} +
+
+ 当前结果 + {{ total_count }} +
+
+ 当前页 + {{ page }} / {{ total_pages }} +
+
+
+
+ +
+
+ + +
+
+

Actions

+

新增内容

+

创建新的引擎家族和初始配置。

+
+ 新增引擎 +
+
+ +
+
+
+

Results

+

结果列表

+

每页 30 条,单行压缩并补充海平面/真空核心参数。

+
+
+ + {% if catalog_rows %} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for row in catalog_rows %} + + + + + + + + + + + + + + + {% endfor %} + +
+
+ Engine + {% for column in sort_columns if column.key == 'engine_name' %} + + + + + {% endfor %} +
+
CycleFuelModConfigs +
+ Mass + {% for column in sort_columns if column.key == 'mass_t' %} + + + + + {% endfor %} +
+
+
+ SL Thrust + {% for column in sort_columns if column.key == 'sl_thrust' %} + + + + + {% endfor %} +
+
+
+ Vac Thrust + {% for column in sort_columns if column.key == 'vac_thrust' %} + + + + + {% endfor %} +
+
+
+ SL ISP + {% for column in sort_columns if column.key == 'sl_isp' %} + + + + + {% endfor %} +
+
+
+ Vac ISP + {% for column in sort_columns if column.key == 'vac_isp' %} + + + + + {% endfor %} +
+
TWRAction
+ {{ row.family.engine_name }} +
{{ row.family.part_name or "No part name" }}
+
{{ row.family.cycle or "-" }}{{ row.fuel_types|join(', ') if row.fuel_types else "-" }}{{ row.mod_sources|join(', ') if row.mod_sources else "-" }}{{ row.config_count }}{{ row.mass_t|fmt_decimal }}{{ row.sl_thrust_kn|fmt_decimal }}{{ row.vac_thrust_kn|fmt_decimal }}{{ row.sl_isp|fmt_decimal }}{{ row.vac_isp|fmt_decimal }}{{ row.twr|fmt_decimal }} +
+ 详情 + 编辑 +
+ +
+
+
+
+ + {% if total_pages > 1 %} + + {% endif %} + {% else %} +
+

没有命中结果

+

调整筛选条件,或者重置后重新查看全部引擎。

+
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/fuel_converter.html b/app/templates/fuel_converter.html new file mode 100644 index 0000000..0224970 --- /dev/null +++ b/app/templates/fuel_converter.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Fuel Chart 工具化

+

燃料体积 / 质量快速换算

+

+ 系数直接来自工作簿第二行。输入升数可换算吨位,输入吨位可反推不同燃料对应的体积。 +

+
+
+ +
+
+
+

换算输入

+

输入一个值

+
+ +
+ + + + + + + + +
+
+ +
+
+

换算结果

+

输出列表

+
+ +
输入 75 t 或 75 L 后,这里会显示结果。
+
+
+
+ +
+
+

当前系数

+

每升对应吨位

+
+
+ {% for factor in fuel_factors %} +
+

{{ factor.fuel }}

+

{{ factor.tonnes_per_liter }} t / L

+
+ {% endfor %} +
+
+ + +{% endblock %} diff --git a/app/templates/mission_asset_log_preview.html b/app/templates/mission_asset_log_preview.html new file mode 100644 index 0000000..f8af814 --- /dev/null +++ b/app/templates/mission_asset_log_preview.html @@ -0,0 +1,113 @@ +{% extends "base.html" %} + +{% block content %} +{% include "_mission_preview_header.html" %} +{% include "_mission_preview_controls.html" %} + +
+
+
+
+
+

Asset Summary

+

{{ asset.name }}

+

{{ asset.asset_type }} · {{ asset.program }}

+
+
+ {{ asset.current_state }} + {{ asset.current_location }} +
+
+ +
+
+
Current Mission
+
{{ asset.current_mission }}
+
+
+
Active Window
+
{{ asset.state_window }}
+
+
+
Next Planned Event
+
{{ asset.next_event }}
+
+
+
+ +
+
+

History

+

Asset Timeline Entries

+
+ +
+ {% for entry in entries %} +
+
+ +
+ +
+
+ {% if entry.mode == 'event' %}Event Point{% else %}State Interval{% endif %} + {{ entry.record_status }} +
+ +

{{ entry.title }}

+ +

+ {{ entry.timestamp }} + {% if entry.end_timestamp %} + → {{ entry.end_timestamp }} + {% endif %} + · {{ entry.location }} +

+ +

{{ entry.summary }}

+
+
+ {% endfor %} +
+
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/mission_list.html b/app/templates/mission_list.html new file mode 100644 index 0000000..87c6991 --- /dev/null +++ b/app/templates/mission_list.html @@ -0,0 +1,137 @@ +{% extends "base.html" %} +{% from "_datetime_picker_field.html" import datetime_picker_field %} + +{% block content %} +{% include "_mission_ops_tabs.html" %} +{% include "_mission_preview_header.html" %} + +
+
+ + +
+ Mission Label 检索与筛选 +
+ + + + + {{ datetime_picker_field('开始', 'start', range_start_input, field_id='mission-list-start') }} + {{ datetime_picker_field('结束', 'end', range_end_input, field_id='mission-list-end') }} + + 重置 +
+
+
+
+ +
+
+ Asset: {{ selected_asset_name }} + Mission: {{ mission_label_filter or (search_term or '全部') }} + 地点: {{ location_filter or '全部地点' }} + 日志: {{ filtered_entry_count }} / {{ total_entry_count }} +
+
+ +{% if mission_groups %} + {% for group in mission_groups %} +
+
+
+

Mission Label

+

{{ group.mission_label }}

+

{{ group.status_label }} · {{ group.asset_count }} 个 Asset · {{ group.entry_count }} 条日志

+
+ 最近条目 {{ group.latest_timestamp }} +
+ +
+ Assets: {{ group.asset_names|join(' · ') }} + 地点: {{ group.locations|join(' · ') }} +
+ +
+ + + + + + + + + + + + {% for entry in group.entries %} + + + + + + + + {% endfor %} + +
TimeAssetEntryLocationAction
+ {{ entry.timestamp }} + {% if entry.end_timestamp %} +
→ {{ entry.end_timestamp }}
+ {% endif %} +
{{ entry.status_label }}
+
+ {{ entry.asset_name }} +
{{ entry.asset_type }}
+
+ {{ entry.title }} +
{{ entry.mode_label }}
+
{{ entry.summary }}
+ {% if entry.state_labels or entry.mission_labels %} + + {% endif %} +
{{ entry.location }} + +
+
+
+ {% endfor %} +{% else %} +
+

当前筛选条件下没有 Mission 日志

+

调整 Asset、时间、Mission Label、地点筛选,或者清空 Mission Label 搜索后重试。

+
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/mission_location_board_preview.html b/app/templates/mission_location_board_preview.html new file mode 100644 index 0000000..f3d869b --- /dev/null +++ b/app/templates/mission_location_board_preview.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} +{% from "_datetime_picker_field.html" import datetime_picker_field %} + +{% block content %} +{% include "_mission_ops_tabs.html" %} +{% include "_mission_preview_header.html" %} + +
+
+
+ 面板筛选 +
+ + + + + 重置 +
+
+
+ +
+ +
+
+ {% if location_groups %} + {% for group in location_groups %} +
+
+
+

地点分组

+

{{ group.title }}

+

{{ group.subtitle }}

+
+ {{ group['items']|length }} 项资产 +
+ +
+ {% for item in group['items'] %} +
+
+
+

{{ item.name }}

+

{{ item.asset_type }}

+
+ {{ item.state }} +
+ +
+
+
Location
+
{{ item.location }}
+
+
+
Mission
+
{{ item.mission }}
+
+
+
Next Event
+
{{ item.next_event }}
+
+
+ + +
+ {% endfor %} +
+
+ {% endfor %} + {% else %} +
+

当前筛选条件下没有资产

+
+ {% endif %} +
+ +
+ {% include "_mission_upcoming_events.html" %} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/mission_status_board_preview.html b/app/templates/mission_status_board_preview.html new file mode 100644 index 0000000..8cdde2f --- /dev/null +++ b/app/templates/mission_status_board_preview.html @@ -0,0 +1,111 @@ +{% extends "base.html" %} +{% from "_datetime_picker_field.html" import datetime_picker_field %} + +{% block content %} +{% include "_mission_ops_tabs.html" %} +{% include "_mission_preview_header.html" %} + +
+
+
+ 面板筛选 +
+ + + + + 重置 +
+
+
+ +
+ +
+
+ {% if status_groups %} + {% for group in status_groups %} +
+
+
+

状态分组

+

{{ group.title }}

+
+ {{ group['items']|length }} 项资产 +
+ +
+ {% for item in group['items'] %} +
+
+
+

{{ item.name }}

+

{{ item.asset_type }}

+
+ {{ item.record_status }} +
+ +
+
+
Location
+
{{ item.location }}
+
+
+
Mission
+
{{ item.mission }}
+
+
+
State Window
+
{{ item.state_window }}
+
+
+
Next Event
+
{{ item.next_event }}
+
+
+ +

{{ item.summary }}

+ + +
+ {% endfor %} +
+
+ {% endfor %} + {% else %} +
+

当前筛选条件下没有资产

+
+ {% endif %} +
+ +
+ {% include "_mission_upcoming_events.html" %} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/mission_timeline_preview.html b/app/templates/mission_timeline_preview.html new file mode 100644 index 0000000..ad5a4c7 --- /dev/null +++ b/app/templates/mission_timeline_preview.html @@ -0,0 +1,240 @@ +{% extends "base.html" %} +{% from "_datetime_picker_field.html" import datetime_picker_field %} + +{% block content %} +{% include "_mission_ops_tabs.html" %} +{% include "_mission_preview_header.html" %} + +
+
+ +
+ 时间范围 +
+ {{ datetime_picker_field('开始', 'start', timeline_range_start, field_id='timeline-start') }} + {{ datetime_picker_field('结束', 'end', timeline_range_end, field_id='timeline-end') }} + +
+
+ +
+ 时间线筛选 +
+ + + + + 重置 +
+
+ +
+ 显示资产 + {% if timeline_asset_options %} +
+
+ +
+ + + +
+
+ +
+ {% for option in timeline_asset_options %} + + {% endfor %} +
+
+

未勾选任何 Asset 时,默认显示当前筛选命中的全部资产。

+ {% else %} +

当前筛选条件下没有可选资产。

+ {% endif %} +
+
+
+ +
+
+
+

Timeline

+

多资产时间线

+
+
+ 转移段 + 持续运行 + 停泊 / 地表 / 待命 + 当前活动状态 +
+
+ +
+ 范围: {{ timeline_range_display }} + 尺度: {{ timeline_scale_mode|title }} + 资产: {% if selected_asset_count %}已选 {{ selected_asset_count }} 个{% else %}显示 {{ timeline_rows|length }} 条{% endif %} + 模式: {{ record_scope_options|selectattr('value', 'equalto', record_scope)|map(attribute='label')|first }} +
+ + {% if timeline_rows %} +
+
+
+
+
+ {% for label in timeline_scale %} + {{ label }} + {% endfor %} +
+
+ +
+ {% for row in timeline_rows %} +
+ +

{{ row.name }}

+

{{ row.asset_type }}

+

Current Status: {{ row.current_state }}

+
+ +
+
+ {% for segment in row.segments %} + + + {{ segment.label }} + + {% endfor %} +
+ +
+ {% for event in row.events %} + + {% if event.state_node %} + + {% endif %} + {{ event.label }} + + {% endfor %} +
+
+
+ {% endfor %} +
+
+
+ {% else %} +
+

当前时间范围内没有时间线条目

+
+ {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/resource_edit.html b/app/templates/resource_edit.html new file mode 100644 index 0000000..9646e4d --- /dev/null +++ b/app/templates/resource_edit.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} + +{% block content %} +{% if mission_ops_tabs %} +{% include "_mission_ops_tabs.html" %} +{% endif %} +
+
+

{{ eyebrow }}

+

{{ heading }}

+
+
+ +
+ {% set hidden_field_values = hidden_fields|default({}, true) %} + {% for name, value in hidden_field_values.items() %} + + {% endfor %} +
+ {% set option_map = form_options|default({}) %} + {% set combo_custom_option_value = custom_option_value|default('__custom__') %} +
+ {% for field in fields %} + {% set current_value = values.get(field.name) %} + {% if field.kind == 'textarea' %} + + {% elif field.kind == 'checkbox' %} + + {% elif field.kind == 'combo' %} + {% set normalized_value = current_value if current_value is not none else '' %} + {% set options = option_map.get(field.name, []) %} + {% set custom_value = '' if not normalized_value or normalized_value in options else normalized_value %} + + {% else %} + + {% endif %} + {% endfor %} +
+ +
+ + 返回 +
+
+
+ +{% if delete_url %} +
+

删除后不可恢复,请谨慎操作。

+
+ {% set delete_hidden_field_values = delete_hidden_fields|default(hidden_field_values, true) %} + {% for name, value in delete_hidden_field_values.items() %} + + {% endfor %} + +
+
+{% endif %} +{% endblock %} diff --git a/app/templates/tank_list.html b/app/templates/tank_list.html new file mode 100644 index 0000000..f594cff --- /dev/null +++ b/app/templates/tank_list.html @@ -0,0 +1,146 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Tank

+

燃料箱目录

+

支持分页、筛选、排序和快速编辑。

+
+
+
+
+ 总记录 + {{ total_catalog_count }} +
+
+ 当前结果 + {{ total_count }} +
+
+ 当前页 + {{ page }} / {{ total_pages }} +
+
+
+
+ +
+
+

Actions

+

新增内容

+
+ 新增燃料箱 +
+ +
+
+ + + + + + + + + + 重置 +
+ + {% if rows %} +
+ + + + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + + + + {% endfor %} + +
TankFuelVehicleVolume (L)Dry (t)Fuel (t)Wet (t)RatioSourceAction
{{ row.tank_name }}{{ row.fuel_type or '-' }}{{ row.vehicle_name or '-' }}{{ row.tank_volume_l if row.tank_volume_l is not none else '-' }}{{ row.dry_mass_t if row.dry_mass_t is not none else '-' }}{{ row.fuel_mass_t if row.fuel_mass_t is not none else '-' }}{{ row.wet_mass_t if row.wet_mass_t is not none else '-' }}{{ row.mass_ratio if row.mass_ratio is not none else '-' }}{{ row.source or '-' }} +
+ 编辑 +
+ +
+
+
+
+ + {% if total_pages > 1 %} + + {% endif %} + {% else %} +
+

没有命中结果

+

调整筛选条件或重置后重试。

+
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/vehicle_list.html b/app/templates/vehicle_list.html new file mode 100644 index 0000000..57a8d77 --- /dev/null +++ b/app/templates/vehicle_list.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Vehicle

+

载具成本目录

+

支持分页、来源筛选、成本排序与编辑。

+
+
+
+
+ 总记录 + {{ total_catalog_count }} +
+
+ 当前结果 + {{ total_count }} +
+
+ 当前页 + {{ page }} / {{ total_pages }} +
+
+
+
+ +
+
+

Actions

+

新增内容

+
+ 新增载具成本 +
+ +
+
+ + + + + + + + 重置 +
+ + {% if rows %} +
+ + + + + + + + + + + {% for row in rows %} + + + + + + + {% endfor %} + +
VehicleLaunch PriceSourceAction
{{ row.vehicle_name }}{{ row.launch_price if row.launch_price is not none else '-' }}{{ row.source or '-' }} +
+ 编辑 +
+ +
+
+
+
+ + {% if total_pages > 1 %} + + {% endif %} + {% else %} +
+

没有命中结果

+

调整筛选条件或重置后重试。

+
+ {% endif %} +
+{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..faf6580 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + web: + build: + context: . + env_file: + - .env + ports: + - "${APP_PORT:-8000}:8000" + restart: unless-stopped + command: gunicorn --bind 0.0.0.0:8000 main:app diff --git a/job.md b/job.md index e69de29..24e850c 100644 --- a/job.md +++ b/job.md @@ -0,0 +1,82 @@ +我们需要搭建一个基于python+flask架构的后端服务器+一个前端界面,来实现一些数据的管理工作。 +我会给你一些信息,然后我们开始讨论这个项目要落地需要怎么做。你把关键信息整理下来,如果有问题可以和我沟通。 +沟通到你认为没有问题了,我们就开始动手搭建这个项目。 + +后端服务器连接的是pg库,连接信息已经保存在.env文件里面了。你不可以查看.env文件,因为里面有敏感信息。但是有一个.env.example文件,你可以查看这个文件,来了解一下连接信息的格式。 + + +这是一个个人项目,前后端都应当部署在我的NAS上,以docker的形式运行。 +你可以先查看KSP Engine Tweak Chart.xlsx,这个里面记载了我需要管理的数据 +我先给你解释一下里面的表结构: + +- Engine Database表:这个表记录了所有的火箭发动机的数据。某种程度上来说算是这个项目的核心表。 +- Communication表:记录了所有的天线数据 +- Work Area表:记录了一些WIP的引擎信息,你可以忽略 +- Fuel Chart: 记录了不同燃料的一个快速换算信息。你可以查看一下这个表,尤其是里面公式的部分,这个可以作为一个快速换算工具来实现(后续不依赖这个表)。这部分你可以先看,肯定会有一些问题,我们讨论实现。 +- Tank Chart:记录了一些燃料箱的数据 +- KSP Vehicle Cost:记录了一些发射载具的成本数据 +- Tube、TQ series:予以忽略 + +后续我们会根据这个表来设计数据库的表结构,来实现数据的管理功能。 +引擎表是核心,你需要搞清楚里面每个字段的含义,来设计数据库表结构和前端界面。你看一下这个表,有什么不理解的地方可以问我。 +你先把表都弄清楚,然后我们再讨论一下这个项目的功能需求,来设计数据库表结构和前端界面,接下来把表上传到数据库,随后我们就可以开始实现数据的管理功能了。 + + +第一轮问题: +## 6. 第一轮需要你确认的问题 + +下面这些问题会直接影响数据库设计和前端表单设计: + +### 关于 `Engine Database` + +1. `Work Env` 的枚举含义是什么? + - 目前看到像 `Lower`、`Upper` 等,是否表示适用飞行环境/任务场景? + - Answer:是的,这是一个引擎的分类表示,表示这些引擎是上面级引擎还是起飞级引擎 + +2. `Classification` 是什么语义? + - 是模组来源(如 `Squad`、`NFS`、`Ven`、`Real/...`)还是引擎分类标签? + - Answer:是Mod来源 + +3. `Name` 是不是游戏/模组里的**内部 part name**? + - Answer:是的。为空的原因是有一些我没有记录上去。这些可以先置空,后续我慢慢补 + +4. `Config Name` 是不是某个引擎的配置名/variant 名? + - 如果是,它显然不是全局唯一键;后续唯一标识你倾向于用什么? + - `Engine + Fuel Type + Config Name`?还是另有内部 ID? + - Answer:应当使用Engine + Config Name作为唯一ID。如果你发现有重复的,反馈给我,我来确认是什么情况 + +5. `Price KD` 是不是 Kerbal Dollars 的部件价格? + - Answer:是的 +6. `entry Cost` 是不是科技树解锁成本 / 研发成本? + - Answer:是研发成本 +7. `TVC` 的单位是否是“度”(gimbal range)? + Answer:是的 +8. `Size` 的单位是否是米(或零件直径规格)? + Answer:是米 +9. `Ignitions` 为空时表示: + - Answer:无限次点火 +10. `Note` 与 `Config Note` 的区别是什么? + - Answer:一个是引擎总体备注,一个是某个配置备注 + +### 关于 `Fuel Chart` + +11. 这个换算功能你希望最终怎么实现? + - 单独页面小工具 + +12. 第二行这些值,本质上是不是“燃料密度 / 升到吨的换算系数”? + - 是的 + +### 关于 `KSP Vehicle Cost` + +13. `Launch Price` 为空的行,是不是表示“暂无数据,允许为空”? + - 是的 + +## 7. 下一步建议 + +等你回答上面的问题后,我建议立即进入下面阶段: + +1. 先把 `Engine Database` 字段定义彻底确认 +2. 画出数据库草图(PostgreSQL) +3. 确定 Flask 后端模块划分 +4. 确定前端页面:列表 / 搜索 / 详情 / 编辑 / 导入 +5. 再开始初始化项目结构、Docker、数据库迁移、Excel 导入脚本 \ No newline at end of file diff --git a/log_book.xlsx b/log_book.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..663bc2142280d1c0f5c77c644167453db898c746 GIT binary patch literal 26884 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 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/0f1b9bb2156c_add_detail_to_asset_state_nodes.py b/migrations/versions/0f1b9bb2156c_add_detail_to_asset_state_nodes.py new file mode 100644 index 0000000..d746496 --- /dev/null +++ b/migrations/versions/0f1b9bb2156c_add_detail_to_asset_state_nodes.py @@ -0,0 +1,26 @@ +"""add detail to asset state nodes + +Revision ID: 0f1b9bb2156c +Revises: f4f0c9c9426e +Create Date: 2026-04-25 23:15:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0f1b9bb2156c' +down_revision = 'f4f0c9c9426e' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('asset_state_nodes', schema=None) as batch_op: + batch_op.add_column(sa.Column('detail', sa.Text(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table('asset_state_nodes', schema=None) as batch_op: + batch_op.drop_column('detail') \ No newline at end of file diff --git a/migrations/versions/6c2a57f2b1e9_add_asset_log_tables.py b/migrations/versions/6c2a57f2b1e9_add_asset_log_tables.py new file mode 100644 index 0000000..6bba7cf --- /dev/null +++ b/migrations/versions/6c2a57f2b1e9_add_asset_log_tables.py @@ -0,0 +1,71 @@ +"""add asset log tables + +Revision ID: 6c2a57f2b1e9 +Revises: 83be4d38636d +Create Date: 2026-04-24 09:40:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6c2a57f2b1e9' +down_revision = '83be4d38636d' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'assets', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('asset_type', sa.String(length=120), nullable=False), + sa.Column('program', sa.String(length=255), nullable=True), + sa.Column('home_region', sa.String(length=120), nullable=True), + sa.Column('note', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name'), + ) + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_assets_asset_type'), ['asset_type'], unique=False) + batch_op.create_index(batch_op.f('ix_assets_name'), ['name'], unique=False) + + op.create_table( + 'asset_log_entries', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('asset_id', sa.Uuid(), nullable=False), + sa.Column('entry_kind', sa.String(length=16), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('state_label', sa.String(length=120), nullable=True), + sa.Column('mission_label', sa.String(length=255), nullable=True), + sa.Column('location', sa.String(length=255), nullable=True), + sa.Column('start_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('end_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('summary', sa.Text(), nullable=True), + sa.Column('note', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['asset_id'], ['assets.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('asset_log_entries', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_asset_log_entries_asset_id'), ['asset_id'], unique=False) + batch_op.create_index(batch_op.f('ix_asset_log_entries_start_at'), ['start_at'], unique=False) + + +def downgrade(): + with op.batch_alter_table('asset_log_entries', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_asset_log_entries_start_at')) + batch_op.drop_index(batch_op.f('ix_asset_log_entries_asset_id')) + + op.drop_table('asset_log_entries') + + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_assets_name')) + batch_op.drop_index(batch_op.f('ix_assets_asset_type')) + + op.drop_table('assets') \ No newline at end of file diff --git a/migrations/versions/83be4d38636d_expand_communication_range_precision.py b/migrations/versions/83be4d38636d_expand_communication_range_precision.py new file mode 100644 index 0000000..b581498 --- /dev/null +++ b/migrations/versions/83be4d38636d_expand_communication_range_precision.py @@ -0,0 +1,46 @@ +"""expand communication range precision + +Revision ID: 83be4d38636d +Revises: f1fa035d3afe +Create Date: 2026-04-23 14:39:48.339810 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '83be4d38636d' +down_revision = 'f1fa035d3afe' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('communication_parts', schema=None) as batch_op: + batch_op.alter_column('range_raw', + existing_type=sa.NUMERIC(precision=18, scale=3), + type_=sa.Numeric(precision=24, scale=3), + existing_nullable=True) + batch_op.alter_column('range_km', + existing_type=sa.NUMERIC(precision=18, scale=3), + type_=sa.Numeric(precision=24, scale=3), + existing_nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('communication_parts', schema=None) as batch_op: + batch_op.alter_column('range_km', + existing_type=sa.Numeric(precision=24, scale=3), + type_=sa.NUMERIC(precision=18, scale=3), + existing_nullable=True) + batch_op.alter_column('range_raw', + existing_type=sa.Numeric(precision=24, scale=3), + type_=sa.NUMERIC(precision=18, scale=3), + existing_nullable=True) + + # ### end Alembic commands ### diff --git a/migrations/versions/8bbf69ea2013_add_retired_flag_to_assets.py b/migrations/versions/8bbf69ea2013_add_retired_flag_to_assets.py new file mode 100644 index 0000000..6ea105e --- /dev/null +++ b/migrations/versions/8bbf69ea2013_add_retired_flag_to_assets.py @@ -0,0 +1,29 @@ +"""add retired flag to assets + +Revision ID: 8bbf69ea2013 +Revises: 0f1b9bb2156c +Create Date: 2026-04-26 01:25:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8bbf69ea2013' +down_revision = '0f1b9bb2156c' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_retired', sa.Boolean(), nullable=False, server_default=sa.false())) + + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.alter_column('is_retired', server_default=None) + + +def downgrade(): + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.drop_column('is_retired') \ No newline at end of file diff --git a/migrations/versions/f1fa035d3afe_initial_schema.py b/migrations/versions/f1fa035d3afe_initial_schema.py new file mode 100644 index 0000000..987c70d --- /dev/null +++ b/migrations/versions/f1fa035d3afe_initial_schema.py @@ -0,0 +1,154 @@ +"""initial schema + +Revision ID: f1fa035d3afe +Revises: +Create Date: 2026-04-23 14:38:17.484705 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f1fa035d3afe' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('communication_parts', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('part_name', sa.String(length=255), nullable=False), + sa.Column('display_name', sa.String(length=255), nullable=True), + sa.Column('mass_t', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('is_deployable', sa.Boolean(), nullable=True), + sa.Column('antenna_type', sa.String(length=120), nullable=True), + sa.Column('deployed_diameter_m', sa.Numeric(precision=10, scale=3), nullable=True), + sa.Column('range_raw', sa.Numeric(precision=18, scale=3), nullable=True), + sa.Column('range_km', sa.Numeric(precision=18, scale=3), nullable=True), + sa.Column('range_au', sa.Numeric(precision=18, scale=6), nullable=True), + sa.Column('range_light_year', sa.Numeric(precision=18, scale=6), nullable=True), + sa.Column('angle_deg', sa.Numeric(precision=10, scale=3), nullable=True), + sa.Column('speed', sa.Numeric(precision=10, scale=3), nullable=True), + sa.Column('idle_power_watt', sa.Numeric(precision=12, scale=4), nullable=True), + sa.Column('idle_power_text', sa.String(length=64), nullable=True), + sa.Column('transmitting_power_watt', sa.Numeric(precision=12, scale=4), nullable=True), + sa.Column('transmitting_power_text', sa.String(length=64), nullable=True), + sa.Column('source', sa.String(length=120), nullable=True), + sa.Column('entry_cost', sa.Integer(), nullable=True), + sa.Column('cost', sa.Integer(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('rescale_factor', sa.Numeric(precision=8, scale=3), nullable=True), + sa.Column('tweakscale', sa.String(length=64), nullable=True), + sa.Column('is_feeder', sa.Boolean(), nullable=True), + sa.Column('tech_required', sa.String(length=120), nullable=True), + sa.Column('note', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('communication_parts', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_communication_parts_part_name'), ['part_name'], unique=False) + + op.create_table('engine_families', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('engine_name', sa.String(length=255), nullable=False), + sa.Column('part_name', sa.String(length=255), nullable=True), + sa.Column('cycle', sa.String(length=120), nullable=True), + sa.Column('size_m', sa.Numeric(precision=8, scale=3), nullable=True), + sa.Column('entry_cost', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('engine_name') + ) + op.create_table('tank_specs', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tank_name', sa.String(length=255), nullable=False), + sa.Column('fuel_type', sa.String(length=120), nullable=True), + sa.Column('dry_mass_t', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('fuel_mass_t', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('wet_mass_t', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('tank_volume_l', sa.Numeric(precision=12, scale=3), nullable=True), + sa.Column('mass_ratio', sa.Numeric(precision=12, scale=4), nullable=True), + sa.Column('kiloliters_per_ton', sa.Numeric(precision=12, scale=4), nullable=True), + sa.Column('vehicle_name', sa.String(length=120), nullable=True), + sa.Column('source', sa.String(length=120), nullable=True), + sa.Column('note', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('tank_specs', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_tank_specs_tank_name'), ['tank_name'], unique=False) + + op.create_table('vehicle_costs', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('vehicle_name', sa.String(length=255), nullable=False), + sa.Column('launch_price', sa.Integer(), nullable=True), + sa.Column('source', sa.String(length=120), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('vehicle_costs', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_vehicle_costs_vehicle_name'), ['vehicle_name'], unique=False) + + op.create_table('engine_variants', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('family_id', sa.Uuid(), nullable=False), + sa.Column('fuel_type', sa.String(length=120), nullable=False), + sa.Column('work_env', sa.String(length=32), nullable=True), + sa.Column('sl_isp', sa.Numeric(precision=10, scale=3), nullable=True), + sa.Column('vac_isp', sa.Numeric(precision=10, scale=3), nullable=True), + sa.Column('min_thrust_kn', sa.Numeric(precision=12, scale=3), nullable=True), + sa.Column('max_thrust_kn', sa.Numeric(precision=12, scale=3), nullable=True), + sa.Column('mass_t', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('twr', sa.Numeric(precision=12, scale=4), nullable=True), + sa.Column('throttle_ratio', sa.Numeric(precision=8, scale=4), nullable=True), + sa.Column('tvc_deg', sa.Numeric(precision=8, scale=3), nullable=True), + sa.Column('price_kd', sa.Integer(), nullable=True), + sa.Column('ignitions', sa.Integer(), nullable=True), + sa.Column('has_unlimited_ignitions', sa.Boolean(), nullable=False), + sa.Column('mod_source', sa.String(length=120), nullable=True), + sa.Column('engine_note', sa.Text(), nullable=True), + sa.Column('config_name', sa.String(length=255), nullable=True), + sa.Column('tech_required', sa.String(length=120), nullable=True), + sa.Column('config_note', sa.Text(), nullable=True), + sa.Column('source_sheet_row', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['family_id'], ['engine_families.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('engine_variants', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_engine_variants_config_name'), ['config_name'], unique=False) + batch_op.create_index(batch_op.f('ix_engine_variants_family_id'), ['family_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('engine_variants', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_engine_variants_family_id')) + batch_op.drop_index(batch_op.f('ix_engine_variants_config_name')) + + op.drop_table('engine_variants') + with op.batch_alter_table('vehicle_costs', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_vehicle_costs_vehicle_name')) + + op.drop_table('vehicle_costs') + with op.batch_alter_table('tank_specs', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_tank_specs_tank_name')) + + op.drop_table('tank_specs') + op.drop_table('engine_families') + with op.batch_alter_table('communication_parts', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_communication_parts_part_name')) + + op.drop_table('communication_parts') + # ### end Alembic commands ### diff --git a/migrations/versions/f4f0c9c9426e_add_asset_state_nodes.py b/migrations/versions/f4f0c9c9426e_add_asset_state_nodes.py new file mode 100644 index 0000000..8598096 --- /dev/null +++ b/migrations/versions/f4f0c9c9426e_add_asset_state_nodes.py @@ -0,0 +1,41 @@ +"""add asset state nodes + +Revision ID: f4f0c9c9426e +Revises: 6c2a57f2b1e9 +Create Date: 2026-04-25 21:20:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f4f0c9c9426e' +down_revision = '6c2a57f2b1e9' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'asset_state_nodes', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('entry_id', sa.Uuid(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('at_time', sa.DateTime(timezone=True), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['entry_id'], ['asset_log_entries.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('asset_state_nodes', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_asset_state_nodes_entry_id'), ['entry_id'], unique=False) + batch_op.create_index(batch_op.f('ix_asset_state_nodes_at_time'), ['at_time'], unique=False) + + +def downgrade(): + with op.batch_alter_table('asset_state_nodes', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_asset_state_nodes_at_time')) + batch_op.drop_index(batch_op.f('ix_asset_state_nodes_entry_id')) + + op.drop_table('asset_state_nodes') \ No newline at end of file diff --git a/project_discovery.md b/project_discovery.md index 082fb08..293572d 100644 --- a/project_discovery.md +++ b/project_discovery.md @@ -236,3 +236,56 @@ 4. 确定前端页面:列表 / 搜索 / 详情 / 编辑 / 导入 5. 再开始初始化项目结构、Docker、数据库迁移、Excel 导入脚本 +## 8. 第二轮落地结论(已按 Excel 实际数据校验) + +### 8.1 唯一键结论 + +- `Engine + Config Name` 在当前工作簿中有 `48` 组重复。 +- 即使补上 `Fuel Type`,仍然还有 `19` 组重复。 +- 这意味着数据库不能直接把自然键硬编码成唯一约束,否则导入会失败。 + +### 8.2 `Engine Database` 的稳定字段边界 + +按同名 `Engine` 聚合后,当前数据呈现出下面的规律: + +- 基本稳定:`Name`、`Cycle`、`Size`、`entry Cost` +- 明显会变化:`Fuel Type`、`Work Env`、`TVC`、`Price KD`、`Classification`、`Note`、`Config Name` + +所以更稳妥的拆分是: + +1. `engine_families` + - `engine_name` + - `part_name` + - `cycle` + - `size_m` + - `entry_cost` +2. `engine_variants` + - 所有配置级字段 + - 使用代理主键 + - 保留原始自然键字段用于展示和导入告警 + +### 8.3 当前实现层面的架构选择 + +- 先采用 **单 Flask 应用**:同时提供 + - Jinja 前端页面 + - JSON API + - PostgreSQL 数据访问层 +- 这样部署到 NAS 时只需要一个应用容器,复杂度最低。 +- 如果后续页面复杂度变高,再把前端拆成独立工程也不迟。 + +### 8.4 已确定的第一批落地内容 + +1. Flask 项目骨架 +2. SQLAlchemy 数据模型 +3. Dockerfile 与 docker-compose 基线 +4. Excel 巡检脚本 +5. Fuel Chart 小工具页面与 API + +### 8.5 下一阶段直接衔接的工作 + +1. 增加数据库迁移目录 +2. 实现 Excel -> ORM 的导入脚本 +3. 先完成 Engine 列表、详情、编辑页 +4. 再补 Communication / Tank / Vehicle Cost 的管理页 + + diff --git a/prototypes/mission-ops/asset-log.html b/prototypes/mission-ops/asset-log.html new file mode 100644 index 0000000..ee98b7e --- /dev/null +++ b/prototypes/mission-ops/asset-log.html @@ -0,0 +1,316 @@ + + + + + + Asset Log Prototype + + + +

+
+ + Mission Ops Prototype + Asset Log + + +
+ +
+
+

Single Asset Detail

+

让单个资产的事件点和状态段在一页里同时可读

+

这页不是普通日志表,而是当前状态、未来计划、历史记录和录入入口的组合页。这里用 XH-02 Taibai 作为样例对象。

+
+
+
+ Simulation Time +

2060-03-12 09:00

+

Current state block and highlighted entry are both interpreted against this clock.

+
+
+
+ Current Status + Transit +
+
+ Next Event + 03-29 +
+
+ Entries + 8 +
+
+
+
+ +
+
+ + + + + +
+
+ Relative labels are derived from selected time + No manual Confirmed / Planned field on the log view +
+
+ +
+
+
+
+
+
+
+

Asset Summary

+

XH-02 Taibai

+

Exploration Mothership · Mars Expedition Program

+
+ In Transit +
+
+
+
Current Location
+
Earth to Mars Transfer
+
+
+
Current Mission
+
Support Mars One Campus
+
+
+
State Window
+
2060-02-26 06:00 → 2060-03-29 18:00
+
+
+
Next Planned Event
+
Mars Arrival · 2060-03-29 18:00
+
+
+
+ Crew Transfer Support + Mars System + Heavy Logistics +
+
+ +
+
+
+

Current Derived State

+

Operational Snapshot

+
+
+
+
+
Status
+
In Transit
+
+
+
Planned Arrival
+
2060-03-29
+
+
+
Transfer Duration
+
31 days
+
+
+
Relative Mode
+
Derived from selected clock
+
+
+
+
+
+ +
+
+
+

History

+

Timeline Entries

+

Event Point 和 State Interval 混排,但视觉上明确区分。

+
+
+ +
+
+
+
+
+
+ 2058-03-15 10:20 +

First module delivered to Star Port

+
+ Event Point +
+

Core transfer spine and habitat ring unloaded for final assembly at Star Port.

+
+
+ +
+
+
+
+
+ 2059-08-06 09:00 +

Commissioned

+
+ Event Point +
+

Flight certification signed. Taibai was cleared for Mars transport duties.

+
+
+ +
+
+
+
+
+ 2059-08-08 04:10 → 2059-10-17 13:40 +

Outbound transfer carrying Mars Expedition 3

+
+ State Interval +
+

Departure from Earth with full crew and descent stack attached. This is the kind of interval that should drive board state during the whole travel window.

+
+
+ +
+
+
+
+
+ 2059-10-17 13:40 +

Mars Arrival

+
+ Event Point +
+

Insertion burn nominal. Crew descent staging began next day.

+
+
+ +
+
+
+
+
+ 2059-10-18 08:00 → 2059-10-19 06:10 +

Mars orbit standby and crew pickup window

+
+ State Interval +
+

Received Mars Expedition 1 return crew after descent vehicle separation.

+
+
+ +
+
+
+
+
+ 2060-02-26 06:00 → 2060-03-29 18:00 +

Support mission transfer to Mars One Campus

+
+ State Interval +
+

Carrying structural truss and greenhouse kit; 105 km/s, 31-day transition. This interval is the one currently lighting up Status Board.

+
+
+ +
+
+
+
+
+ 2060-03-29 18:00 +

Mars Arrival

+
+ Event Point +
+

Planned capture burn and cargo handoff to surface fleet.

+
+
+
+
+
+ + +
+
+ + + \ No newline at end of file diff --git a/prototypes/mission-ops/index.html b/prototypes/mission-ops/index.html new file mode 100644 index 0000000..4019f9f --- /dev/null +++ b/prototypes/mission-ops/index.html @@ -0,0 +1,206 @@ + + + + + + Mission Operations Prototype + + + +
+
+ + KSP Static Prototype + Mission Operations + + +
+ +
+
+

Standalone Review Build

+

载具与设施任务运营原型

+

这是一套独立静态原型,不依赖 Flask 模板渲染。你现在双击这些 HTML 文件,浏览器里看到的就是页面设计本身,而不是 Jinja 源码。

+
+ Asset-first model + Event Point + State Interval + Simulation Time driven + Planned + Current + Past +
+
+
+
+ Prototype Clock +

2060-03-12 09:00

+

原型里所有当前状态、未来事件和时间线高亮,都围绕这个手动设置的 Simulation Time 进行解释。

+
+
+
+ Tracked Assets + 14 +
+
+ Active Missions + 6 +
+
+ Upcoming 30d + 9 +
+
+
+
+ +
+
+ + + + + +
+
+ Current state and next event are always relative to selected clock + Overview now includes recent past and upcoming event lists +
+
+ +
+
+
+
+
+

Mission Center

+

当前正在运行的关键任务

+

首页主区域不再是页面目录,而是更像值班台,优先告诉你当前系统里什么任务正在跑。

+
+
+
+
+
+
+

Loading active operations

+

Preparing mission center feed

+
+
+

This list will update relative to the selected clock.

+
+
+
+ +
+
+
+

Attention Queue

+

接下来需要处理的事项

+

这一栏更接近任务队列,不是简单的未来事件列表。它应该告诉你“值班时下一步要盯什么”。

+
+
+
+
+
+
+

Loading attention queue

+

Preparing next actions

+
+
+

This queue will show near-term actions relative to the selected clock.

+
+
+
+ +
+
+
+

Operational Shortcuts

+

工作面板入口

+

四个详细页面仍然保留,但在首页里它们应该是任务中心的工作入口,而不是首页主内容本身。

+
+
+ +
+
+ + +
+
+ + + \ No newline at end of file diff --git a/prototypes/mission-ops/location-board.html b/prototypes/mission-ops/location-board.html new file mode 100644 index 0000000..60207d7 --- /dev/null +++ b/prototypes/mission-ops/location-board.html @@ -0,0 +1,260 @@ + + + + + + Location Board Prototype + + + +
+
+ + Mission Ops Prototype + Location Board + + +
+ +
+
+

Location-first Board

+

按地点分组看每个系统当前都聚集了什么资产

+

这张板子和 Status Board 是并列关系,不是一个页面里的切换模式。它更适合回答“Earth System 现在有什么、Mars System 现在有什么”。

+
+
+
+ Simulation Time +

2060-03-12 09:00

+

Grouping is based on current location hierarchy. Status still appears on each card so the operational meaning remains visible.

+
+
+
+ Location Groups + 4 +
+
+ Earth System + 3 +
+
+ Mars System + 2 +
+
+
+
+ +
+
+ + + + + +
+
+ + + + + Apply + Reset +
+
+ Grouping: Location hierarchy + Status preserved on each asset card + Supports Station, Port, Surface Site later +
+
+ +
+
+
+
+
+

Location Group

+

Earth System

+

Ports, orbital stations, and near-Earth transfer lanes.

+
+ 3 Assets +
+
+
+
+
+

Lingxiao Station

+

Station

+
+ Docked / On Station +
+

Low Kerbin Orbit. Continuous habitation and logistics hub.

+
+ Next: Expedition 16 Arrival · 2060-03-30 + Current crew handover prep +
+
+
+
+
+

XH-03 Zhuque

+

Shuttle

+
+ At Port +
+

Star Port. Refit phase for Jupiter relay deployment mission.

+
+ Next: Rollout Review · 2060-04-12 + Bay 4 +
+
+
+
+
+

Amalthea Crew Vehicle 4

+

Shuttle

+
+ In Transit +
+

LKO transfer corridor. Planned crew rotation for Lingxiao Station.

+
+ Next: Docking Window · 2060-03-30 + 10 crew manifest +
+
+
+
+ +
+
+
+

Location Group

+

Mars System

+

Orbital operations and deployed surface infrastructure around Mars.

+
+ 2 Assets +
+
+
+
+
+

Mars One Campus Alpha

+

Surface Outpost

+
+ Surface Ops +
+

Primary surface site. Habitat expansion, ISRU staging, and logistics intake point.

+
+ Next: Taibai Supply Window · 2060-03-29 + Needs truss + greenhouse kit +
+
+
+
+
+

XH-02 Taibai

+

Exploration Mothership

+
+ In Transit +
+

Mars transfer lane. Support mission inbound for Mars One Campus surface expansion.

+
+ Next: Mars Arrival · 2060-03-29 + 31-day transfer +
+
+
+
+ +
+
+
+

Location Group

+

Deep Space / Future Hooks

+

Reserved for future hierarchy like Jupiter System, relay chains, and transit corridors.

+
+ Prototype Placeholder +
+
+
+ Location hierarchy will matter + Earth, Star Port, Low Kerbin Orbit, Lingxiao Station should eventually form a parent-child location structure. +
+
+ Facilities are first-class assets + Stations, ports, and surface outposts appear here exactly like ships, which is why the model must be Asset-first. +
+
+
+
+ + +
+
+ + + \ No newline at end of file diff --git a/prototypes/mission-ops/script.js b/prototypes/mission-ops/script.js new file mode 100644 index 0000000..bb90f0b --- /dev/null +++ b/prototypes/mission-ops/script.js @@ -0,0 +1,629 @@ +const SIM_TIME_STORAGE_KEY = "mission-ops-sim-time"; +const DEFAULT_SIM_TIME = "2060-03-12T09:00"; + +const OVERVIEW_EVENTS = [ + { when: "2059-12-27 09:00", title: "XH-02 Taibai parked to Star Port", scope: "Earth System" }, + { when: "2059-12-27 12:00", title: "Mars Expedition 1 crew returned to Earth", scope: "Earth System" }, + { when: "2060-02-26 06:00", title: "XH-02 Taibai departed Star Port for Mars support mission", scope: "Earth System" }, + { when: "2060-03-18 10:00", title: "Tianhe Cargo Pod 7 undock from Lingxiao Station", scope: "Lingxiao Station" }, + { when: "2060-03-29 18:00", title: "XH-02 Taibai arrives at Mars", scope: "Mars System" }, + { when: "2060-03-30 08:00", title: "Expedition 16 crew arrives via Amalthea Crew Vehicle", scope: "Lingxiao Station" }, + { when: "2060-04-02 09:00", title: "Expedition 15 crew departs Lingxiao Station", scope: "Lingxiao Station" }, + { when: "2060-04-12 14:00", title: "XH-03 Zhuque rollout review", scope: "Star Port" }, +].map((event) => ({ + ...event, + date: parseDateTime(event.when), +})); + +const OVERVIEW_ACTIVE_OPERATIONS = [ + { + asset: "XH-02 Taibai", + assetType: "Exploration Mothership", + location: "Earth to Mars Transfer", + mission: "Support Mars One Campus", + start: "2060-02-26T06:00", + end: "2060-03-29T18:00", + next: "Mars Arrival · 2060-03-29 18:00", + note: "Carrying structural truss, greenhouse kit, and support cargo.", + }, + { + asset: "Lingxiao Station", + assetType: "Station", + location: "Low Kerbin Orbit", + mission: "Continuous habitation and logistics hub", + start: "2059-07-23T00:00", + end: "", + next: "Expedition 16 Arrival · 2060-03-30 08:00", + note: "Crew handover preparation and consumables balancing in progress.", + }, + { + asset: "Tianhe Cargo Pod 7", + assetType: "Cargo Shuttle", + location: "Lingxiao Forward Berth", + mission: "Dry cargo replenishment", + start: "2060-03-01T12:00", + end: "2060-03-18T10:00", + next: "Undock · 2060-03-18 10:00", + note: "Return trash loading remains before departure.", + }, + { + asset: "XH-03 Zhuque", + assetType: "Shuttle", + location: "Star Port", + mission: "Jupiter relay refit", + start: "2060-01-08T09:00", + end: "2060-04-12T14:00", + next: "Rollout Review · 2060-04-12 14:00", + note: "Main engine refurbishment at bay 4 and payload integration queued.", + }, + { + asset: "Mars One Campus Alpha", + assetType: "Surface Outpost", + location: "Mars Surface", + mission: "Habitat expansion and ISRU staging", + start: "2059-10-20T08:00", + end: "", + next: "Taibai Supply Window · 2060-03-29 18:00", + note: "Awaiting new structural truss and greenhouse package delivery.", + }, +]; + +const OVERVIEW_ATTENTION_ITEMS = [ + { + title: "Review Tianhe Cargo Pod 7 undock checklist", + asset: "Tianhe Cargo Pod 7", + priority: "high", + due: "2060-03-18T10:00", + summary: "Finalize return trash manifest and station berth reconfiguration.", + }, + { + title: "Finalize Expedition 16 docking prep", + asset: "Lingxiao Station", + priority: "high", + due: "2060-03-26T18:00", + summary: "Confirm medical bay allocation, handover timeline, and EVA support window.", + }, + { + title: "Prepare XH-02 Taibai Mars arrival burn package", + asset: "XH-02 Taibai", + priority: "high", + due: "2060-03-29T18:00", + summary: "Review capture burn, cargo handoff staging, and surface drop order.", + }, + { + title: "Approve XH-03 Zhuque rollout readiness package", + asset: "XH-03 Zhuque", + priority: "medium", + due: "2060-04-12T14:00", + summary: "Complete avionics validation and relay payload handling plan.", + }, +]; + +const TIMELINE_ASSET_CATALOG = [ + { id: "taibai", name: "XH-02 Taibai", type: "Exploration Mothership", region: "Mars System", note: "Mars support transfer" }, + { id: "lingxiao", name: "Lingxiao Station", type: "Station", region: "Earth System", note: "Crew rotation hub" }, + { id: "campus-alpha", name: "Mars One Campus Alpha", type: "Surface Outpost", region: "Mars System", note: "Surface habitat expansion" }, + { id: "amalthea-4", name: "Amalthea Crew Vehicle 4", type: "Shuttle", region: "Earth System", note: "Crew rotation shuttle" }, + { id: "zhuque", name: "XH-03 Zhuque", type: "Shuttle", region: "Earth System", note: "Relay deployment refit" }, + { id: "tianhe-7", name: "Tianhe Cargo Pod 7", type: "Cargo Shuttle", region: "Earth System", note: "Station logistics shuttle" }, +]; + +const TIMELINE_PRESETS = { + active: ["taibai", "lingxiao", "campus-alpha"], + earth: ["lingxiao", "amalthea-4", "zhuque", "tianhe-7"], + mars: ["taibai", "campus-alpha"], + crew: ["lingxiao", "amalthea-4", "taibai"], +}; + +let selectedTimelineAssets = new Set(["taibai", "lingxiao", "campus-alpha"]); + +function parseDateTime(value) { + if (!value) { + return null; + } + const normalized = value.includes("T") ? value : value.replace(" ", "T"); + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function padNumber(value) { + return String(value).padStart(2, "0"); +} + +function toInputValue(date) { + return [ + date.getFullYear(), + "-", + padNumber(date.getMonth() + 1), + "-", + padNumber(date.getDate()), + "T", + padNumber(date.getHours()), + ":", + padNumber(date.getMinutes()), + ].join(""); +} + +function toDisplayValue(date) { + return [ + date.getFullYear(), + "-", + padNumber(date.getMonth() + 1), + "-", + padNumber(date.getDate()), + " ", + padNumber(date.getHours()), + ":", + padNumber(date.getMinutes()), + ].join(""); +} + +function toShortDate(date) { + return [padNumber(date.getMonth() + 1), "-", padNumber(date.getDate())].join(""); +} + +function shiftDays(date, dayOffset) { + const nextDate = new Date(date); + nextDate.setDate(nextDate.getDate() + dayOffset); + return nextDate; +} + +function formatRelativeDayLabel(simDate, targetDate) { + const dayDelta = Math.round((targetDate - simDate) / (1000 * 60 * 60 * 24)); + if (dayDelta < 0) { + return `${Math.abs(dayDelta)}d ago`; + } + if (dayDelta === 0) { + return "Today"; + } + return `in ${dayDelta}d`; +} + +function renderEventFeed(container, events, emptyLabel) { + if (!container) { + return; + } + + if (!events.length) { + container.innerHTML = ` +
+ +

${emptyLabel}

+

Change the simulation time to inspect a different period.

+
+ `; + return; + } + + container.innerHTML = events + .map( + (event) => ` +
+ +

${event.title}

+

${event.scope}

+
+ ` + ) + .join(""); +} + +function renderOverviewOperations(simDate) { + const container = document.getElementById("active-ops-list"); + if (!container) { + return; + } + + const activeOperations = OVERVIEW_ACTIVE_OPERATIONS.filter((item) => { + const start = parseDateTime(item.start); + const end = parseDateTime(item.end || ""); + return start && start <= simDate && (!end || simDate < end); + }); + + if (!activeOperations.length) { + container.innerHTML = ` +
+
+
+

No active operations

+

No State Interval covers the selected clock

+
+
+
+ `; + return; + } + + container.innerHTML = activeOperations + .map( + (item) => ` +
+
+
+

${item.asset}

+

${item.assetType}

+
+ ${item.location} +
+
+
+
Mission
+
${item.mission}
+
+
+
Next Event
+
${item.next}
+
+
+

${item.note}

+
+ ` + ) + .join(""); +} + +function renderAttentionQueue(simDate) { + const container = document.getElementById("attention-queue-list"); + if (!container) { + return; + } + + const queueItems = OVERVIEW_ATTENTION_ITEMS + .map((item) => ({ ...item, dueDate: parseDateTime(item.due) })) + .filter((item) => item.dueDate && item.dueDate >= shiftDays(simDate, -7)) + .sort((left, right) => left.dueDate - right.dueDate) + .slice(0, 4); + + if (!queueItems.length) { + container.innerHTML = ` +
+
+
+

No pending queue items

+

Nothing falls into the current operational window

+
+
+
+ `; + return; + } + + container.innerHTML = queueItems + .map((item) => { + const priorityClass = item.dueDate < simDate ? "overdue" : item.priority; + const priorityLabel = item.dueDate < simDate ? "Overdue" : item.priority; + return ` +
+
+
+

${item.title}

+

${item.asset}

+
+ ${priorityLabel} +
+
+
+
Due
+
${toDisplayValue(item.dueDate)}
+
+
+
Relative
+
${formatRelativeDayLabel(simDate, item.dueDate)}
+
+
+

${item.summary}

+
+ `; + }) + .join(""); +} + +function updateOverview(simDate) { + if (document.body.dataset.page !== "overview") { + return; + } + + const pastEvents = OVERVIEW_EVENTS + .filter((event) => event.date && event.date <= simDate) + .sort((left, right) => right.date - left.date) + .slice(0, 4) + .map((event) => ({ ...event, when: toDisplayValue(event.date) })); + + const futureEvents = OVERVIEW_EVENTS + .filter((event) => event.date && event.date > simDate) + .sort((left, right) => left.date - right.date) + .slice(0, 4) + .map((event) => ({ ...event, when: toDisplayValue(event.date) })); + + renderOverviewOperations(simDate); + renderAttentionQueue(simDate); + renderEventFeed(document.getElementById("recent-past-events"), pastEvents, "No recent past events"); + renderEventFeed(document.getElementById("recent-future-events"), futureEvents, "No upcoming events"); +} + +function updateAssetLog(simDate) { + if (document.body.dataset.page !== "asset-log") { + return; + } + + const entries = Array.from(document.querySelectorAll(".entry[data-start]")); + let currentStateEntry = null; + let nextEventEntry = null; + + entries.forEach((entry) => { + const badge = entry.querySelector("[data-entry-badge]"); + const start = parseDateTime(entry.dataset.start); + const end = parseDateTime(entry.dataset.end || ""); + const kind = entry.dataset.kind; + + if (!badge || !start) { + return; + } + + badge.classList.remove("completed", "planned", "active"); + + if (kind === "state") { + if (start <= simDate && (!end || simDate < end)) { + badge.textContent = "Current Interval"; + badge.classList.add("active"); + currentStateEntry = entry; + } else if (simDate < start) { + badge.textContent = "Upcoming Interval"; + badge.classList.add("planned"); + } else { + badge.textContent = "Past Interval"; + badge.classList.add("completed"); + } + } else if (simDate < start) { + badge.textContent = "Upcoming Event"; + badge.classList.add("planned"); + } else { + badge.textContent = "Past Event"; + badge.classList.add("completed"); + } + + if (start > simDate && (!nextEventEntry || start < parseDateTime(nextEventEntry.dataset.start))) { + nextEventEntry = entry; + } + }); + + const currentStateText = document.querySelector("[data-current-state]"); + const currentStatePill = document.querySelector("[data-current-state-pill]"); + const currentLocation = document.querySelector("[data-current-location]"); + const currentWindow = document.querySelector("[data-current-window]"); + const nextEventText = document.querySelector("[data-next-event]"); + const nextEventShort = document.querySelector("[data-next-event-short]"); + const nextEventDate = document.querySelector("[data-next-event-date]"); + const currentDuration = document.querySelector("[data-current-duration]"); + const relativeMode = document.querySelector("[data-relative-mode]"); + + if (currentStateEntry) { + const start = parseDateTime(currentStateEntry.dataset.start); + const end = parseDateTime(currentStateEntry.dataset.end || ""); + const stateLabel = currentStateEntry.dataset.stateLabel || currentStateEntry.dataset.title; + if (currentStateText) currentStateText.textContent = stateLabel; + if (currentStatePill) currentStatePill.textContent = stateLabel; + if (currentLocation) currentLocation.textContent = currentStateEntry.dataset.location || "Unknown"; + if (currentWindow) currentWindow.textContent = `${toDisplayValue(start)} → ${end ? toDisplayValue(end) : "Open"}`; + if (currentDuration && end) { + const totalDays = Math.round((end - start) / (1000 * 60 * 60 * 24)); + currentDuration.textContent = `${totalDays} days`; + } + if (relativeMode) relativeMode.textContent = "Derived from active State Interval"; + } else { + if (currentStateText) currentStateText.textContent = "No Active Interval"; + if (currentStatePill) currentStatePill.textContent = "No Active Interval"; + if (currentLocation) currentLocation.textContent = "Unknown"; + if (currentWindow) currentWindow.textContent = "No interval covers selected time"; + if (currentDuration) currentDuration.textContent = "-"; + if (relativeMode) relativeMode.textContent = "Derived: no active interval at selected time"; + } + + if (nextEventEntry) { + const nextDate = parseDateTime(nextEventEntry.dataset.start); + const nextLabel = `${nextEventEntry.dataset.title} · ${toDisplayValue(nextDate)}`; + if (nextEventText) nextEventText.textContent = nextLabel; + if (nextEventShort) nextEventShort.textContent = toDisplayValue(nextDate); + if (nextEventDate) nextEventDate.textContent = toShortDate(nextDate); + } else { + if (nextEventText) nextEventText.textContent = "No future event recorded"; + if (nextEventShort) nextEventShort.textContent = "-"; + if (nextEventDate) nextEventDate.textContent = "-"; + } +} + +function getTimelineFilteredAssets() { + const searchInput = document.querySelector("[data-asset-search]"); + const typeFilter = document.querySelector("[data-asset-type-filter]"); + const regionFilter = document.querySelector("[data-asset-region-filter]"); + const searchTerm = (searchInput?.value || "").trim().toLowerCase(); + const selectedType = typeFilter?.value || "all"; + const selectedRegion = regionFilter?.value || "all"; + + return TIMELINE_ASSET_CATALOG.filter((asset) => { + const matchesSearch = !searchTerm || `${asset.name} ${asset.type} ${asset.region} ${asset.note}`.toLowerCase().includes(searchTerm); + const matchesType = selectedType === "all" || asset.type === selectedType; + const matchesRegion = selectedRegion === "all" || asset.region === selectedRegion; + return matchesSearch && matchesType && matchesRegion; + }); +} + +function updateTimelineSelection() { + if (document.body.dataset.page !== "timeline") { + return; + } + + const rows = Array.from(document.querySelectorAll(".timeline-row[data-asset]")); + const selectedAssets = Array.from(selectedTimelineAssets); + const selectedNames = TIMELINE_ASSET_CATALOG.filter((asset) => selectedTimelineAssets.has(asset.id)).map((asset) => asset.name); + + rows.forEach((row) => { + row.classList.toggle("is-hidden", !selectedAssets.includes(row.dataset.asset)); + }); + + const selectedCount = document.querySelector("[data-selected-count]"); + const selectedSummary = document.querySelector("[data-selected-assets]"); + + if (selectedCount) { + selectedCount.textContent = String(selectedAssets.length); + } + + if (selectedSummary) { + selectedSummary.textContent = selectedNames.length ? `Selected: ${selectedNames.join(", ")}` : "Selected: none"; + } +} + +function renderTimelineSelector() { + if (document.body.dataset.page !== "timeline") { + return; + } + + const resultsContainer = document.querySelector("[data-asset-results]"); + const selectedContainer = document.querySelector("[data-selected-asset-list]"); + if (!resultsContainer || !selectedContainer) { + return; + } + + const filteredAssets = getTimelineFilteredAssets(); + const selectedAssetsData = TIMELINE_ASSET_CATALOG.filter((asset) => selectedTimelineAssets.has(asset.id)); + + resultsContainer.innerHTML = filteredAssets.length + ? filteredAssets + .map( + (asset) => ` +
+
+
+

${asset.name}

+

${asset.type}

+
+ ${asset.region} +
+

${asset.note}

+
+ ${selectedTimelineAssets.has(asset.id) ? "Already selected" : "Available to add"} + +
+
+ ` + ) + .join("") + : ` +
+
+
+

No matching assets

+

Try another search or filter combination

+
+
+
+ `; + + selectedContainer.innerHTML = selectedAssetsData.length + ? selectedAssetsData + .map( + (asset) => ` +
+
+
+

${asset.name}

+

${asset.type}

+
+ ${asset.region} +
+
+ ${asset.note} + +
+
+ ` + ) + .join("") + : ` +
+ No assets selected +

Add assets from the search results to build the current timeline focus set.

+
+ `; + + resultsContainer.querySelectorAll("[data-add-asset]").forEach((button) => { + button.addEventListener("click", () => { + selectedTimelineAssets.add(button.dataset.addAsset); + renderTimelineSelector(); + updateTimelineSelection(); + }); + }); + + selectedContainer.querySelectorAll("[data-remove-asset]").forEach((button) => { + button.addEventListener("click", () => { + selectedTimelineAssets.delete(button.dataset.removeAsset); + renderTimelineSelector(); + updateTimelineSelection(); + }); + }); +} + +function setupTimelineSelection() { + if (document.body.dataset.page !== "timeline") { + return; + } + + document.querySelectorAll("[data-asset-search], [data-asset-type-filter], [data-asset-region-filter]").forEach((control) => { + control.addEventListener(control.tagName === "INPUT" ? "input" : "change", renderTimelineSelector); + }); + + document.querySelectorAll("[data-asset-preset]").forEach((button) => { + button.addEventListener("click", () => { + selectedTimelineAssets = new Set(TIMELINE_PRESETS[button.dataset.assetPreset] || []); + renderTimelineSelector(); + updateTimelineSelection(); + }); + }); + + renderTimelineSelector(); + updateTimelineSelection(); +} + +function syncSimulationTime(simDate) { + const inputValue = toInputValue(simDate); + const displayValue = toDisplayValue(simDate); + + document.querySelectorAll("[data-sim-time-input]").forEach((input) => { + if (input.value !== inputValue) { + input.value = inputValue; + } + }); + + document.querySelectorAll("[data-sim-time-display]").forEach((display) => { + display.textContent = displayValue; + }); + + localStorage.setItem(SIM_TIME_STORAGE_KEY, inputValue); + updateOverview(simDate); + updateAssetLog(simDate); +} + +document.addEventListener("DOMContentLoaded", () => { + let simDate = parseDateTime(localStorage.getItem(SIM_TIME_STORAGE_KEY)) || parseDateTime(DEFAULT_SIM_TIME); + + document.querySelectorAll("[data-sim-time-input]").forEach((input) => { + input.addEventListener("input", (event) => { + const nextDate = parseDateTime(event.target.value); + if (!nextDate) { + return; + } + simDate = nextDate; + syncSimulationTime(simDate); + }); + }); + + document.querySelectorAll("[data-time-shift]").forEach((button) => { + button.addEventListener("click", () => { + simDate = shiftDays(simDate, Number(button.dataset.timeShift)); + syncSimulationTime(simDate); + }); + }); + + syncSimulationTime(simDate); + setupTimelineSelection(); +}); \ No newline at end of file diff --git a/prototypes/mission-ops/status-board.html b/prototypes/mission-ops/status-board.html new file mode 100644 index 0000000..db44837 --- /dev/null +++ b/prototypes/mission-ops/status-board.html @@ -0,0 +1,398 @@ + + + + + + Status Board Prototype + + + +
+
+ + Mission Ops Prototype + Status Board + + +
+ +
+
+

Current State Board

+

按状态分组看当前有哪些资产在执行什么任务

+

这个页面只回答一件事:在当前 Simulation Time 下,每个 Asset 正处于哪个状态、在哪里、在做什么,以及下一个关键事件是什么。

+
+
+
+ Simulation Time +

2060-03-12 09:00

+

Current status is derived from active State Interval. Planned events remain visible on the board.

+
+
+
+ Active Assets + 8 +
+
+ In Transit + 2 +
+
+ Upcoming 7d + 3 +
+
+
+
+ +
+
+ + + + + +
+
+ Set Simulation Time +
+
+ + + + + Apply + Reset +
+
+ Grouping: Current State + Location used as secondary filter + Future planned events remain visible +
+
+ +
+
+
+
+
+

State Group

+

In Transit

+

Assets currently travelling between systems or operational nodes.

+
+ 2 Assets +
+ +
+
+
+
+

XH-02 Taibai

+

Exploration Mothership

+
+ Confirmed +
+
+
+
Location
+
Earth to Mars Transfer
+
+
+
Mission
+
Support Mars One Campus
+
+
+
State Window
+
2060-02-26 → 2060-03-29
+
+
+
Next Event
+
Mars Arrival · 2060-03-29
+
+
+

31-day transition carrying structural truss, greenhouse kit, and support consumables for Mars One Campus Alpha.

+
+ Derived from active State Interval + Open asset log +
+
+ +
+
+
+

Amalthea Crew Vehicle 4

+

Shuttle

+
+ Planned +
+
+
+
Location
+
LKO Transfer Corridor
+
+
+
Mission
+
Expedition 16 Crew Rotation
+
+
+
State Window
+
2060-03-28 → 2060-03-30
+
+
+
Next Event
+
Docking Window · 2060-03-30
+
+
+

Planned departure with 10-crew manifest for Lingxiao Station rotation and medical cargo transfer.

+
+ Visible before execution because Planned stays on board + Open asset log +
+
+
+
+ +
+
+
+

State Group

+

Docked / On Station

+

Stationary orbital assets and docked support craft.

+
+ 2 Assets +
+ +
+
+
+
+

Lingxiao Station

+

Station

+
+ Active +
+
+
+
Location
+
Low Kerbin Orbit
+
+
+
Mission
+
Continuous habitation and logistics hub
+
+
+
State Window
+
2059-07-23 → Present
+
+
+
Next Event
+
Expedition 16 Crew Arrival · 2060-03-30
+
+
+

Expedition 15 currently onboard. Crew handover prep and life-support reserve balancing underway.

+
+ Can later host nested docked assets + Open asset log +
+
+ +
+
+
+

Tianhe Cargo Pod 7

+

Cargo Shuttle

+
+ Confirmed +
+
+
+
Location
+
Lingxiao Forward Berth
+
+
+
Mission
+
Dry cargo replenishment
+
+
+
State Window
+
2060-03-01 → 2060-03-18
+
+
+
Next Event
+
Undock · 2060-03-18
+
+
+

Food, EVA packs, filter cartridges, and station spares are already unloaded. Return trash loading remains.

+
+ Useful test case for attached support craft + Open asset log +
+
+
+
+ +
+
+
+

State Group

+

At Port / Surface Ops

+

Idle, refit, maintenance, and deployed surface infrastructure.

+
+ 2 Assets +
+ +
+
+
+
+

XH-03 Zhuque

+

Shuttle

+
+ Confirmed +
+
+
+
Location
+
Star Port
+
+
+
Mission
+
Refit for Jupiter relay deployment
+
+
+
State Window
+
2060-01-08 → 2060-04-12
+
+
+
Next Event
+
Rollout Review · 2060-04-12
+
+
+

Main engine refurbishment at bay 4. Payload integration queued after avionics test completion.

+
+ Example of a non-flight operational state + Open asset log +
+
+ +
+
+
+

Mars One Campus Alpha

+

Surface Outpost

+
+ Active +
+
+
+
Location
+
Mars Surface
+
+
+
Mission
+
Habitat expansion and ISRU staging
+
+
+
State Window
+
2059-10-20 → Present
+
+
+
Next Event
+
Taibai Supply Window · 2060-03-29
+
+
+

Surface crew awaiting structural truss shipment, greenhouse package, and two new power skids.

+
+ Long-duration persistent state example + Open asset log +
+
+
+
+
+ + +
+
+ + + \ No newline at end of file diff --git a/prototypes/mission-ops/styles.css b/prototypes/mission-ops/styles.css new file mode 100644 index 0000000..01000be --- /dev/null +++ b/prototypes/mission-ops/styles.css @@ -0,0 +1,1084 @@ +:root { + --bg-top: #edf5ff; + --bg-bottom: #dfeafb; + --ink-1: #10233d; + --ink-2: #34506f; + --ink-3: #647d99; + --line: rgba(16, 35, 61, 0.1); + --line-strong: rgba(16, 35, 61, 0.16); + --surface: rgba(255, 255, 255, 0.78); + --surface-strong: rgba(255, 255, 255, 0.92); + --surface-tint: rgba(230, 240, 255, 0.76); + --blue-1: #1c73ea; + --blue-2: #0f4fa6; + --blue-3: #dbeaff; + --teal-1: #0e8f89; + --amber-1: #d9942b; + --rose-1: #b24968; + --shadow-lg: 0 24px 60px rgba(26, 53, 98, 0.12); + --shadow-sm: 0 8px 22px rgba(26, 53, 98, 0.08); + --radius-xl: 30px; + --radius-lg: 22px; + --radius-md: 16px; + --radius-pill: 999px; + --content-width: 1480px; + --font-main: "Segoe UI Variable", "Noto Sans SC", "Microsoft YaHei UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + color: var(--ink-1); + font-family: var(--font-main); + background: + radial-gradient(circle at top left, rgba(28, 115, 234, 0.16), transparent 25%), + radial-gradient(circle at top right, rgba(67, 154, 255, 0.13), transparent 26%), + linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%); +} + +a { + color: inherit; +} + +.site-shell { + width: min(calc(100vw - 24px), var(--content-width)); + margin: 0 auto; + padding: 16px 0 40px; +} + +.topbar { + position: sticky; + top: 10px; + z-index: 20; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 12px 16px; + margin-bottom: 18px; + border: 1px solid var(--line); + border-radius: 22px; + backdrop-filter: blur(18px); + background: rgba(255, 255, 255, 0.7); + box-shadow: var(--shadow-sm); +} + +.brand { + display: inline-flex; + flex-direction: column; + gap: 4px; + text-decoration: none; +} + +.brand-kicker { + color: var(--ink-3); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.22em; + text-transform: uppercase; +} + +.brand-name { + font-size: 26px; + font-weight: 800; + line-height: 1; +} + +.topbar-nav { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.topbar-nav a { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 9px 13px; + border: 1px solid var(--line); + border-radius: var(--radius-pill); + background: rgba(255, 255, 255, 0.76); + text-decoration: none; + color: var(--ink-2); + font-size: 14px; + font-weight: 700; +} + +.topbar-nav a.active { + color: white; + border-color: transparent; + background: linear-gradient(135deg, var(--blue-1), var(--blue-2)); +} + +.panel, +.hero, +.metric-card, +.status-card, +.mini-panel, +.timeline-lane { + border: 1px solid var(--line); + box-shadow: var(--shadow-sm); +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.92fr); + gap: 18px; + padding: 22px; + border-radius: var(--radius-xl); + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(225, 238, 255, 0.78)); +} + +.hero-copy h1, +.section-head h2, +.group-head h2, +.location-head h2, +.entry-head h3, +.timeline-row-label h3 { + margin: 0; +} + +.eyebrow { + margin: 0 0 8px; + color: var(--blue-1); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.hero-copy h1 { + font-size: clamp(32px, 3vw, 48px); + line-height: 1; +} + +.hero-copy p, +.hero-note, +.subtle, +.entry-copy p, +.meta-copy p, +.section-head p { + color: var(--ink-2); + line-height: 1.65; +} + +.hero-side { + display: grid; + gap: 10px; +} + +.time-card { + padding: 18px; + border-radius: var(--radius-lg); + background: linear-gradient(145deg, rgba(233, 243, 255, 0.88), rgba(255, 255, 255, 0.86)); +} + +.time-card .label { + display: block; + color: var(--ink-3); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.time-value { + margin: 10px 0 8px; + color: var(--blue-2); + font-size: 30px; + font-weight: 800; + line-height: 1; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.metric-card { + padding: 14px; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.84); +} + +.metric-value { + display: block; + margin-top: 4px; + font-size: 26px; + font-weight: 800; + line-height: 1; +} + +.metric-label { + color: var(--ink-3); + font-size: 12px; + font-weight: 700; +} + +.panel { + padding: 18px; + border-radius: var(--radius-lg); + background: var(--surface); + backdrop-filter: blur(12px); +} + +.toolbar { + display: grid; + gap: 12px; + margin-top: 16px; +} + +.toolbar-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} + +.clock-field { + display: grid; + gap: 6px; + flex: 1 1 280px; + min-width: min(100%, 280px); +} + +.toolbar input, +.toolbar select, +.composer input, +.composer select, +.composer textarea { + width: 100%; + padding: 11px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.88); + color: var(--ink-1); + font: inherit; +} + +.toolbar input { + flex: 1 1 250px; +} + +.toolbar-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)) auto auto; + gap: 10px; + align-items: end; +} + +.button, +.ghost-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 0 16px; + border-radius: var(--radius-pill); + font-size: 14px; + font-weight: 800; + text-decoration: none; + cursor: default; + white-space: nowrap; +} + +button.button, +button.ghost-button { + font: inherit; + cursor: pointer; +} + +.button { + color: white; + border: 0; + background: linear-gradient(135deg, var(--blue-1), var(--blue-2)); +} + +.ghost-button { + color: var(--ink-2); + border: 1px solid var(--line-strong); + background: rgba(255, 255, 255, 0.8); +} + +.pill-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 10px; + border-radius: var(--radius-pill); + color: var(--blue-2); + background: rgba(28, 115, 234, 0.08); + border: 1px solid rgba(28, 115, 234, 0.12); + font-size: 12px; + font-weight: 700; +} + +.split-layout { + display: grid; + grid-template-columns: minmax(0, 1.45fr) 330px; + gap: 16px; + margin-top: 16px; + align-items: start; +} + +.stack { + display: grid; + gap: 14px; +} + +.section-head, +.group-head, +.location-head, +.asset-summary-head, +.entry-head, +.timeline-toolbar { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + flex-wrap: wrap; +} + +.section-head p, +.group-head p, +.location-head p, +.timeline-toolbar p { + margin: 4px 0 0; +} + +.count-badge, +.status-pill, +.tone-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px 10px; + border-radius: var(--radius-pill); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.count-badge { + color: var(--ink-3); + background: rgba(255, 255, 255, 0.84); + border: 1px solid var(--line); +} + +.status-pill { + color: var(--blue-2); + background: rgba(28, 115, 234, 0.1); + border: 1px solid rgba(28, 115, 234, 0.14); +} + +.tone-pill.active { + color: var(--teal-1); + background: rgba(14, 143, 137, 0.12); + border: 1px solid rgba(14, 143, 137, 0.18); +} + +.tone-pill.planned { + color: var(--amber-1); + background: rgba(217, 148, 43, 0.12); + border: 1px solid rgba(217, 148, 43, 0.18); +} + +.tone-pill.completed { + color: var(--ink-2); + background: rgba(100, 125, 153, 0.12); + border: 1px solid rgba(100, 125, 153, 0.16); +} + +.group-stack, +.location-stack, +.upcoming-list, +.warning-list, +.composer, +.entry-list { + display: grid; + gap: 12px; +} + +.status-card, +.mini-panel, +.timeline-row-label { + border-radius: 20px; + background: var(--surface-strong); +} + +.status-card { + display: grid; + gap: 12px; + padding: 16px; +} + +.status-card.blue { + background: linear-gradient(180deg, rgba(220, 234, 255, 0.84), rgba(255, 255, 255, 0.92)); +} + +.status-card.teal { + background: linear-gradient(180deg, rgba(223, 246, 244, 0.84), rgba(255, 255, 255, 0.92)); +} + +.status-card.amber { + background: linear-gradient(180deg, rgba(255, 243, 224, 0.84), rgba(255, 255, 255, 0.92)); +} + +.status-card.soft { + background: linear-gradient(180deg, rgba(240, 245, 251, 0.92), rgba(255, 255, 255, 0.92)); +} + +.card-top { + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.card-top h3, +.location-item h3, +.mini-panel h3 { + margin: 0; +} + +.asset-type { + margin: 4px 0 0; + color: var(--ink-3); + font-size: 13px; + font-weight: 700; +} + +.meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; +} + +.meta-item dt { + margin: 0; + color: var(--ink-3); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.meta-item dd { + margin: 5px 0 0; + font-weight: 700; + line-height: 1.45; +} + +.summary-text { + margin: 0; + color: var(--ink-2); + line-height: 1.55; +} + +.card-foot, +.location-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + padding-top: 10px; + border-top: 1px dashed var(--line); + color: var(--ink-3); + font-size: 12px; + font-weight: 700; +} + +.mini-panel { + padding: 16px; +} + +.upcoming-item, +.warning-item, +.location-item { + padding: 14px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.78); +} + +.upcoming-item time, +.entry-time { + color: var(--blue-1); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.warning-item strong { + display: block; + margin-bottom: 6px; +} + +.location-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.ops-list, +.attention-list, +.selector-results-list, +.selected-assets-list { + display: grid; + gap: 12px; +} + +.ops-item, +.attention-item, +.asset-result-card, +.selected-asset-card { + padding: 15px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.84); +} + +.ops-item, +.attention-item, +.asset-result-card, +.selected-asset-card { + display: grid; + gap: 10px; +} + +.attention-item { + background: linear-gradient(180deg, rgba(255, 248, 237, 0.92), rgba(255, 255, 255, 0.84)); +} + +.priority-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px 10px; + border-radius: var(--radius-pill); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.priority-chip.high { + color: #a35a00; + background: rgba(217, 148, 43, 0.14); + border: 1px solid rgba(217, 148, 43, 0.18); +} + +.priority-chip.medium { + color: var(--blue-2); + background: rgba(28, 115, 234, 0.1); + border: 1px solid rgba(28, 115, 234, 0.14); +} + +.priority-chip.overdue { + color: var(--rose-1); + background: rgba(178, 73, 104, 0.12); + border: 1px solid rgba(178, 73, 104, 0.16); +} + +.asset-picker-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.selector-controls-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.preset-chip-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.selector-layout { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(300px, 0.85fr); + gap: 12px; +} + +.compact-head h2 { + font-size: 22px; +} + +.asset-result-foot, +.selected-asset-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + color: var(--ink-3); + font-size: 12px; + font-weight: 700; +} + +.selector-add, +.selector-remove { + min-height: 36px; + padding: 0 14px; + border-radius: var(--radius-pill); + font: inherit; + font-weight: 800; + cursor: pointer; +} + +.selector-add { + color: white; + border: 0; + background: linear-gradient(135deg, var(--blue-1), var(--blue-2)); +} + +.selector-add[disabled] { + opacity: 0.5; + cursor: default; +} + +.selector-remove { + color: var(--ink-2); + border: 1px solid var(--line-strong); + background: rgba(255, 255, 255, 0.84); +} + +.asset-option { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.82); +} + +.asset-option input { + width: auto; + margin-top: 3px; +} + +.asset-option strong, +.asset-option small { + display: block; +} + +.asset-option small { + margin-top: 4px; + color: var(--ink-3); +} + +.asset-summary-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 14px; +} + +.asset-summary-card, +.asset-current-card { + padding: 18px; + border: 1px solid var(--line); + border-radius: 20px; + background: rgba(255, 255, 255, 0.82); +} + +.asset-chip-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 14px; +} + +.chip { + display: inline-flex; + align-items: center; + padding: 7px 10px; + border-radius: var(--radius-pill); + background: rgba(28, 115, 234, 0.08); + color: var(--blue-2); + font-size: 12px; + font-weight: 700; +} + +.chip.strong { + background: rgba(28, 115, 234, 0.14); +} + +.entry { + display: grid; + grid-template-columns: 36px minmax(0, 1fr); + gap: 12px; +} + +.entry-rail { + position: relative; + display: flex; + justify-content: center; +} + +.entry-rail::before { + content: ""; + position: absolute; + top: 12px; + bottom: -14px; + width: 2px; + background: rgba(16, 35, 61, 0.1); +} + +.entry-dot { + position: relative; + z-index: 1; + width: 14px; + height: 14px; + margin-top: 6px; + border-radius: 50%; + border: 3px solid rgba(255, 255, 255, 0.94); + background: var(--blue-1); + box-shadow: 0 0 0 2px rgba(28, 115, 234, 0.16); +} + +.entry.state .entry-dot { + background: var(--teal-1); + box-shadow: 0 0 0 2px rgba(14, 143, 137, 0.16); +} + +.entry-copy { + padding: 15px 16px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.82); +} + +.composer { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.composer .span-2 { + grid-column: span 2; +} + +.composer textarea { + min-height: 140px; + resize: vertical; +} + +.timeline-panel { + overflow: hidden; +} + +.timeline-toolbar { + margin-bottom: 12px; +} + +.legend-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.legend-item { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: var(--radius-pill); + background: rgba(255, 255, 255, 0.84); + border: 1px solid var(--line); + color: var(--ink-2); + font-size: 12px; + font-weight: 700; +} + +.legend-swatch { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.legend-swatch.transit { + background: rgba(28, 115, 234, 0.48); +} + +.legend-swatch.ops { + background: rgba(14, 143, 137, 0.48); +} + +.legend-swatch.hold { + background: rgba(100, 125, 153, 0.44); +} + +.legend-swatch.current { + background: rgba(28, 115, 234, 0.84); +} + +.timeline-scroll { + overflow-x: auto; + padding-bottom: 6px; +} + +.timeline-frame { + min-width: 1280px; +} + +.timeline-scale { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 12px; + margin-bottom: 8px; +} + +.timeline-scale-strip { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 8px; +} + +.timeline-scale-strip span { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + color: var(--ink-3); + font-size: 12px; + font-weight: 800; +} + +.timeline-rows { + display: grid; + gap: 14px; +} + +.timeline-row { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 12px; +} + +.timeline-row.is-hidden { + display: none; +} + +.timeline-row-label { + padding: 14px; +} + +.timeline-row-label p { + margin: 4px 0 0; + color: var(--ink-3); +} + +.timeline-lane { + position: relative; + display: grid; + grid-template-columns: repeat(96, minmax(8px, 1fr)); + gap: 4px; + min-height: 92px; + padding: 16px 6px 30px; + border-radius: 20px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(234, 242, 255, 0.8)); +} + +.timeline-segment { + display: inline-flex; + align-items: center; + justify-content: center; + align-self: center; + min-height: 32px; + padding: 0 10px; + border-radius: var(--radius-pill); + border: 1px solid transparent; + color: var(--ink-1); + font-size: 12px; + font-weight: 800; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.timeline-segment.transit { + background: rgba(28, 115, 234, 0.16); + border-color: rgba(28, 115, 234, 0.2); +} + +.timeline-segment.ops { + background: rgba(14, 143, 137, 0.16); + border-color: rgba(14, 143, 137, 0.2); +} + +.timeline-segment.hold { + background: rgba(100, 125, 153, 0.14); + border-color: rgba(100, 125, 153, 0.18); +} + +.timeline-segment.current { + box-shadow: 0 0 0 2px rgba(28, 115, 234, 0.16); +} + +.timeline-event { + position: relative; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.timeline-event::before { + content: ""; + position: absolute; + top: 10px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--blue-2); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.96), 0 0 0 4px rgba(28, 115, 234, 0.14); +} + +.timeline-event span { + position: absolute; + bottom: 0; + transform: translateX(52%); + width: 112px; + color: var(--ink-3); + font-size: 11px; + line-height: 1.35; +} + +.landing-grid { + display: grid; + grid-template-columns: 1.15fr 0.85fr; + gap: 16px; + margin-top: 16px; +} + +.command-layout { + display: grid; + grid-template-columns: minmax(0, 1.5fr) 360px; + gap: 16px; + margin-top: 16px; + align-items: start; +} + +.link-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.shortcut-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.link-card { + display: grid; + gap: 10px; + padding: 18px; + border: 1px solid var(--line); + border-radius: 20px; + background: rgba(255, 255, 255, 0.82); + text-decoration: none; +} + +.link-card h3, +.link-card p, +.info-list p { + margin: 0; +} + +.link-card strong { + color: var(--blue-2); + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.info-list { + display: grid; + gap: 12px; +} + +@media (max-width: 1200px) { + .hero, + .split-layout, + .landing-grid, + .command-layout, + .asset-summary-grid { + grid-template-columns: 1fr; + } + + .toolbar-grid, + .location-grid, + .link-grid, + .shortcut-grid, + .selector-controls-grid, + .selector-layout { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 860px) { + .site-shell { + width: min(calc(100vw - 16px), var(--content-width)); + } + + .topbar, + .toolbar-row, + .card-top, + .card-foot, + .location-foot, + .group-head, + .location-head, + .entry-head, + .timeline-toolbar { + flex-direction: column; + align-items: flex-start; + } + + .metric-grid, + .toolbar-grid, + .meta-grid, + .location-grid, + .asset-picker-grid, + .link-grid, + .shortcut-grid, + .selector-controls-grid, + .selector-layout, + .composer, + .timeline-row, + .timeline-scale { + grid-template-columns: 1fr; + } + + .composer .span-2 { + grid-column: span 1; + } + + .timeline-event span { + position: static; + transform: none; + width: auto; + margin-top: 24px; + } +} \ No newline at end of file diff --git a/prototypes/mission-ops/timeline-view.html b/prototypes/mission-ops/timeline-view.html new file mode 100644 index 0000000..bd5e5a4 --- /dev/null +++ b/prototypes/mission-ops/timeline-view.html @@ -0,0 +1,281 @@ + + + + + + Timeline View Prototype + + + +
+
+ + Mission Ops Prototype + Timeline View + + +
+ +
+
+

Multi-Asset Range View

+

把多个资产在同一时间范围内的行为画到一张真正可读的时间线里

+

这里是甘特式原型。State Interval 用长条表示,Event Point 用节点表示,当前生效中的状态段会高亮。

+
+
+
+ Simulation Time +

2060-03-12 09:00

+

Current active state segment is highlighted relative to 2060-03-12 09:00.

+
+
+
+ Selected Assets + 3 +
+
+ State Segments + 7 +
+
+ Event Points + 9 +
+
+
+
+ +
+
+ + + + + +
+
+ + + + Apply + Export +
+
+ Transit + Continuous Ops + Hold / Port / Orbit + Current State Highlight +
+
+ Selected: XH-02 Taibai, Lingxiao Station, Mars One Campus Alpha +
+
+ +
+
+
+

Asset Selection

+

用搜索、筛选和已选集合来组织 Timeline 对象

+

如果以后 Asset 很多,就不能把所有对象直接平铺出来。所以这里改成一个更接近“选择工作区”的原型。

+
+
+
+ + + +
+
+ + + + +
+
+
+
+
+

Search Results

+

Matching Assets

+
+
+
+
+
+
+

Loading asset search

+

Preparing searchable asset catalog

+
+
+
+
+
+ +
+
+
+

Selected Assets

+

Timeline Focus Set

+
+
+
+
+ Loading selected assets +
+
+
+
+
+ +
+
+
+

Timeline Canvas

+

Mission Flow Across Assets

+

每个 Asset 一条泳道,节点用于关键事件,条带用于持续状态。

+
+
+ +
+
+
+
+
+ 2059-08 + 2059-09 + 2059-10 + 2059-11 + 2059-12 + 2060-01 + 2060-02 + 2060-03 +
+
+ +
+
+
+

XH-02 Taibai

+

Exploration Mothership

+
+
+
Outbound Transfer
+
Mars Orbit Standby
+
Return Transfer
+
Star Port Stay
+
Support Transit to Mars
+ +
Commissioned
+
Mars Arrival
+
Earth Arrival
+
Next Mars Arrival
+
+
+ +
+
+

Lingxiao Station

+

Station

+
+
+
Continuous Station Operations
+ +
Crew 9 Arrival
+
Expedition 15 Arrival
+
Expedition 16 Arrival
+
+
+ +
+
+

Mars One Campus Alpha

+

Surface Outpost

+
+
+
Surface Expansion and ISRU Staging
+ +
First Crew Descent
+
Taibai Supply Window
+
+
+ + + + + + +
+
+
+
+
+ + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4c4b0db --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask>=3.1,<4.0 +Flask-SQLAlchemy>=3.1,<4.0 +Flask-Migrate>=4.0,<5.0 +python-dotenv>=1.0,<2.0 +psycopg[binary]>=3.2,<4.0 +openpyxl>=3.1,<4.0 +gunicorn>=23.0,<24.0 diff --git a/scripts/import_log_book.py b/scripts/import_log_book.py new file mode 100644 index 0000000..174055d --- /dev/null +++ b/scripts/import_log_book.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app import create_app +from app.services.log_book_importer import import_log_book_data + + +def main() -> None: + parser = argparse.ArgumentParser(description="Import mission asset logs from log_book.xlsx.") + parser.add_argument( + "--path", + default=str(PROJECT_ROOT / "log_book.xlsx"), + help="Path to log_book.xlsx.", + ) + args = parser.parse_args() + + app = create_app() + with app.app_context(): + summary = import_log_book_data(args.path) + + print(json.dumps(summary.to_dict(), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/import_workbook.py b/scripts/import_workbook.py new file mode 100644 index 0000000..5b39b8c --- /dev/null +++ b/scripts/import_workbook.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app import create_app +from app.services.workbook_importer import import_workbook_data + + +def main() -> None: + parser = argparse.ArgumentParser(description="Import workbook data into the configured database.") + parser.add_argument("--path", default=None, help="Path to the workbook file.") + parser.add_argument( + "--replace", + action="store_true", + help="Clear imported tables before loading workbook data again.", + ) + args = parser.parse_args() + + app = create_app() + with app.app_context(): + workbook_path = args.path or app.config["WORKBOOK_PATH"] + summary = import_workbook_data(workbook_path=workbook_path, replace_existing=args.replace) + + print(json.dumps(summary.to_dict(), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/inspect_workbook.py b/scripts/inspect_workbook.py new file mode 100644 index 0000000..a619c8d --- /dev/null +++ b/scripts/inspect_workbook.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.services.workbook_inspector import summarize_workbook + + +def main() -> None: + parser = argparse.ArgumentParser(description="Inspect the KSP workbook structure.") + parser.add_argument( + "--path", + default=os.getenv("WORKBOOK_PATH", "KSP Engine Tweak Chart.xlsx"), + help="Path to the workbook file.", + ) + args = parser.parse_args() + + summary = summarize_workbook(args.path) + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main()