>^Fu>@h%eXlUyL@%DdOBhA8h>#X8fk7})(%~W+
z1yhTHsilGm&sQ!dgn{8oQ7#LqTmmhtE^y^4PG`zoh0N((InqM)JiYYv4Bb36+zYc1
ze^)IB`rjKh>?!aI3+N=G`~PmEHVPXx=i%DhJR)el`ab=GWEJ1<_lapd${}qv;EC*;
zwsCSQ^h!*=;D?(0A7j=SC
z3I=u+HL&ZC#zsua>InvRwP<1{c8`_1XvBb>9c}B8eGG6o<;2E`I~%pFQ=TnikqqNy
zW0Y;3<}gP}2|2(`Lj%qfXj`YYcTL2GFWBOfqb<^74eWGSMlRv|4eZ&RMVuJFLNKs{
zmxYW=Jem}l3oRJ*qb?V;5%W6Q-N#P=cqk8;Vcp>nC6pH
z#0eEoDPUfI8jy+sP1<8#!QwE-CYIo<(9jH%NOKxfMSl1-qzU;Qk^CZ`lX3PjW^Z?N
zJ?07Zz&L3OIpYoU$y~;mdJcg3Tqcam5p9ImT*Z7&uOEzAF`u_w#e5z(W-m8&T%-xNULNvH;Y~7O)Vp}B;LDJdBdI`wry^?Fq-QPuI^;!6;QUN@?2M(5ExZk&9b6_@+-E^zNWanJ
zXFzY@1Le3kC!{_BK;C<+AXyrY?;;Z3uENlX&UNDcm{>A+dM;X8Ad2RIgCfcVX
zhc;@p=C8+|Ekbew5>WUIzZhe6NR|Q#+n{-RY`Yn`)B?Q>Vu8QQ_#98oFoKKBsO=eN
zn)e}Dj%}?#ato3KBrB1uLUJpRWd1gc;-Qvbjbzkt91n(LGZ>DQr0w=%C63E`CmN3n
z$l&coU_hQi=GDi85xMHWjmY0)M4l5gRELeo*)xWnRTtHAupn#Ef*eOJ$ZBdqHeIU)
z84SpQu@>a|{-yN;mOJ_lcl^i}GHIB0OYOlpo?CNcaDjJ7|;#9E7hYiKw
zWhXBG!G_}ch@rS`jG_2cqG@pnbE>d9W^tzcowDjoczQQ^ZYc!6(7DVIoXwOkE>xe*
zRbjkDT^A=kTcNAdN`IwbA^t0^9H@X>6raM$?*2I9)?|>ct4m~@^uajvCD6$@fGc=*
zRWp8ul*K-~8k9ZCvxv`2N3&0RGC=&0!a
z9`+D2WUqo@N5xG6yBC#dx>}w&{F0|DIo`-ofvRe
zW#^eB0*KN?@PhE{i1tK9xHI`EkLiu^rir+x>tO!)NrRkI&El;2<-Rm=)*cw${QyC6
zmZR3lY@s^^x>kf|2XI+W5Q&~0%88mCE6fh%C61q+L}3wxQzy=h4wi>rfKkaw
z;wu&Ajd~g&6p9t@)1!K@ILt{FO8^wo3o}eF?~UV76=xF)I3fkYFV6-a52cO4FzzuG
znI}{PBc)B`jPr}&61ns-6(JZ(6-MTWcs@Y08hF9!;tTPQ`VvsEIEhaLPyfb0Ye#LThzV`?$e{o!5?{B!WyQ&1cq$}3WgvY1w%2;+%On=k)&?S
zDMAD^#-tzv##D4{ya6JQvx~r^VK3+k>Mz8S;^h{d=xNS8AlaBp4yGg*<1EziT|k{u
zeh&n~?u{OQUbshK?Dp-#_fG^KI;RtFb4H-3s;RZX-Q=BBRJGpI;@Q&1&w3BHb{yrE
z&zURRnSRIGV;w!d6R!6coLq2b**kUpE8Ibs`+4qI$Jf;T_MXHn_JA^rZI6XApYP!kJWagu6Ej+16|!KWWa2c@tmw99SV&$n8rK
z$otKCVP61i%8<2qz*-!%mhM^zj?+P03PAd~4MBTjzt;9^V-{TV_D^*USmyN`=6&*G
zIS&M`*?Zu}bsh*@v-iLcaUQ4+8fpOdozHM|ZwzKk8RIaHjsr>x`@ms5F>p8E?BlQrt7U1`TaUYy-!VUsWBJR5pb?ix5{$f3Ivcv@Rlnl@x
zD}P51=ugj_o-(%_f;0Jm{`AaQR=!xHKC4h+T(4eSEIn({EzXmk&0;a0CkINp3<;!z
zzmkM%ovOvpUwh@kTRTV9;t2Vvx4?K3H36Q-LJFUo2WuiPA;0uak;tWefAbcw$-l<*
z-yoUru_>9?UTijk%<{j*^jS!LhvZ}OKzq3&4paUodB6Q%*pp<(rfSwg`Zt@&uQwf2
zY)6H)gLG}4&Dwz3q(TO47$=Eq%VIXO@6jzI681xK&s`bpS@QF{s@PoezwWA7mx{%3
zJiHxZ%Rm#u#tR*X?>jv1=8$Cb+AkPJg5UeaG;C;Z*;wyw?DRk`dTz%t!smEPu=uFI
z`U!lbmzqYb95pF<*q>2(68NDQ!J!O4_2qYCGMy(axIDEsZ34$B}^-N5N|kQeWAu-Sd3TWi?BDQpS#Zb-pQoNLJU{g57%x^QxP`(cv~2$(k_d
zn;3Yo`|UK7O!x$as%^~9KLHhqMPq1DuCc+lhQA*Y!RBxX8{3Z=#oPg8<2FqD5Md+t
zKICGBQCtNNhm&w#>P0baY`V~Ep&PRBd46mT1-~m&Lo>Zc9@l#Xzc-SB&hEJMWd5J9
z#@CP>NAeDmK_veJ$?uU|KqAF@lt}2F=)jm0NgmfCgMFRs*gj;$HwY_OUC%Xo!6mO@0|-jf-KA_b
zdFJkw8cgN8k?bJsj;RV43fXhyx*hY!Bt5jFR55?@B=bFGit8s&dj6gY!(y6*4d8$q
zSTZNuMA)4VsIX=5>Rdq{-8n062RwM;zxXx#A@cj3>FkYt(tE39`Tqd?WmH6Koyw4+
z+2h;L$_cCCG9(F>oi1!bLpDC}vgJ{X5+F{qAemHDR*;zwTH|Q}^c@Jvn_4$}c>XQ2
z{=ubF(c>|s!Ef<5c-&||3&}U5=OX_ER1?w&AJu|C0VF++73)B?{M&;mi)nRKWF^=r
zuKbX}v!$V_9ZYs&UHqfi%hgEsK`t-eirb@q0)l;+Ec>aGUD3Dgr-gF1j~wkv)xC;s
znFVp^CPQ77s*s}53vZGqmOajCZ(*BnBf6ZqcOCpe{kOMH6@
z*;mN3dve*1z7O_XVO6vdIe(daKy08{zaVz@IEi^I5B^re^vB#fxc`6Bkqc&-7h*yq(q4%4?wOsMgx}X_$NQQj;>b;{{=J>lEZ?4
z4-?0)+JA?%PD4};G}~d4xbK3Re)I(E{Q^-xm7=A!$nI=yYyr6|dCI}QKvq1pAV9lB
zr;PTC>d0f*!?DxI{}vj8_dA(B!#?mAk&M$aA0rRRevn8n`VZ}7E=g4Kl1nC5o^n|s
zm1o5WGm4qWm+FMRnNJ5<_A-e(P_6wl@H%ych4?2cNa=wjvgJU!;!~{h&*b2NA_e$X
zUwGvq5q5|)i2w>;Zk_!ibO
zx;ei=dvkM#7fxRDwlw-+fz!4Hc_v>!n<0J?aGKt)%Vr&
zaqMllA%zrodh5ZbijQXTY2jjL9X-4tq0GO)dpRYoL%bHUeA9>
zI$v;@Kfp?_!%APm*mjIPMqYiPaw(Xm82%^{y1wa7PjwYt2q+H13ZZWvU#}q3V?{Tl
vsuG+~XYr|hb6#A}>H;dZ0uK5>FT3K&t4C`jbJIxrOG`-VOHR`EQr&+7-LenS
delta 14470
zcmch83s{>~n*aCZMiL+dl0YDVB!pYImbR2)Z_)z2Lu*^LHxc>L7UWXD1bgvIqx;mc
zGgb|rt)&+&uFgttN@Zs3-&L1-t>ZW*4N96}cIvaX>h3yHZ5?%7ci;CUUkKIiKJGsI
zPoMtI{XO^Z{NDE*zI#~u${!?}caoFU4E!opFSY%}cS>{0%sR-h`S)xuc_h1oJvj7T
z_Ipy6kl)#g*nQ-x?HGG}s6Tg)L~#-d@h3@n{#^DX>CDgc{yjwOXPnGBmSfgQI0MJ-
zmaUU=>6`>y8Msn#NqQ>16R-4I6b(A9V$mI
zSy%A3oPPyd@HT*p1+enf
z7=DLrExujxJM{jZd{Den5@5(fk7tulOMA#aluS?kLH02=szL+@t@HzBM<7)Q7a;E?ivM*kN8VYR#4-O|y_cW-k!Qut(y{}SObgewSF
z5xzpW1>shN^$150R{aI>Yk;t{vt>K~FBrTZz>&lcqyHO(ZxQ~A@Eroy%>O&W_Xz)i
zumM2|!^|^7-PP~1iHQ&)Wf=C$VTLrnoUYygo%FCCiNKP}HF`3hlUOKim275OB^#$f
zid2wtY$lWeR}yul5tnFxZAg}J^5Zy6GzZ4aS4_lk35VsqlD$-C449q@l2Ca*4og-s?o_eh@u-JRo5GVI$OVNU
z=hQeQv*Ev6C+39s1W_JTlPKl_H;GQ5oJ&R*YLRf7!*b4$f>qfWw?Y(=$M8Ai>-jen
z@7?WI#zh)EGEU;gl|+AnOza{jBc~QySS|Te;YxCT!4&olqOLRlaO;xlx+=DVbk@zh
zm2RhdKn^be>{Ru*HhtPx$3*rQj$34ZJ%GN4036E5R_e!yZxH?tfq~*Y%HaDj=w-;K
z^@|nl5aCK7GaCxLa#hWk+1j6ZsA<$(6);zw-sU$~1uH=?|8T)`O47t(Vg%XZ-p)~!8cwp~tCWNI$U#l`vc6%(V)
z{eodt%h<_{y@xh-=`WU(4`*5&xbyaz=4+T&HF*p4(pRS|5oaeaNRhs#V!{7fiX5<2
zLZ%q>k}dR;;iYR2HnhMBlDk=6&atq9q;#FamnHI>v`%Fnxf{0${RuLGja!%S0cm4b
zF%Csz!_^eX5SieQPCs<(1k16
zN?MmJUi~o6UjqcM{Zxb!)BD;2iG_4tZVc#Z{YkYSnJa?k%78L+kEV~M6EU!KL^(60
z&Fq`*PqF%yR(>vsB4--YCEqJab7CI(_r|{?l}#CB>tdBUy0H^H3FNL_`En)fVl{cY
z$&sPt5;--FlOW@g=*TI#WJpu3s7%(}nFZ~ab4o4+VivBbte%L`MoT5YH$lmzMnY~C
zG=ctLpF{8P=TQnu5~wlDcrADakC^Av5^0eVn;DOa(|FYIKT(j>F+RygI9EJLD#nc?
zPk%yEOjfc0|Jt?Wlt!1S!7l~=_h{n2Fq(D)rY_ZSx)XYFPGjX$+8B@4oe_}>2S|#T
zQpapu!!UFerFzobD3<<&wDv3#cvMIFUr{FN1f7fBn~f@do4P(F=Mg
z$3hOJpogO>QO0--5NqJ)K;LapgIOl1+U>-)Qm0CS!O!4~?fDq0Fo@kb#5^wHG7n3|
zFv|#uoNgz4>ph8kzk)$e7jU4S{2RTOGo3Jty?{}ugDe@7+EhT>q!siyDDJ|@xIj-b
zMB#VBVaun+jadftmgdG1Vy-ThOMu=KL2r!EoAh?v=dfT4R%)gh#Y`--w?P(PNxGQg
z$rLg<4GtY&`h#Kc;xJ@iufsle@58XVWwfwekbAP+4q7(=rx7xRtmDyd1k8jfIupQ}
zJtiStFu}hpJkPTjXnhj2-s~}Tt#p@&65veB+#(L3CmY0N?}P#I+d+YAfb
zI#I2VE%pnB92(=X2|0pIu+X8Da5@+)4OZ&O6|6!o_1Oh;WX`?m5NFSd4J^#9-DBNb
zB7@FwIUVVp8J;|riQ9@3+b2J6>6>ALSj2WiE1r!l{{jgkiYr3e~%=4g&aC;*vo@OvBrMnSfv4zbTL%LbU`Dp9JCAmq2t5JMC0
zA%?RGserb}WnxP`#n7G_T5a)!JcygTF)EUTV$Kw|S_{O?T<+l*H-Lr=!VZk?DzBi9
z?h&<5viCC>&v*e6QGlQp65VseRticnp0gi@eaYn=o?w;wZ6d#%@R_zDpSeLS4}7NF
zQ-b@MA-OBlmcvSp6J8P%ex1#{mf;vr$*vNgbnmi=ZyURf*)CnnY-1hyZ?UkS5!)?xMHU`PnPkZ=H+!!dApO$eW1rsp^GgpN{p1yL@qrgVKJ@Ixm-o?>
z_=t%k5H*ma68=*ZO%;Kd&T$pj$Hym!5y}(&3+9eB`-H4+6dV*~bTWU)nc#>7i#od2;VjHmtm*o4@mp
z?(RDsY*_AcxA5+8LbGdYN1MBOr@VZ7hif}fa;zwv*xlpa+T(8C+Tz|qYYnSF`Dt$L
z?&)$Lm4)S09}1JwwPr6JBWNJDEu7%$>FjLbckq7;A%}(1{II&s)z$^ob+tPAcOjmi
zj*x&b3m~j$b2W2q{Ez8XVU@G1r_;%|xSjlMSb!=(RHs^x!K;iHkSbQP_U^VW{s|N)
z=UciqJ3-ZI>k2Eow>rDF?%3YJ!_J1fT3X$0cRP99b$mBME0&PCS|BpUFCC>3Ch{}P
zkdE`6fnY?)M4)Q`7f0Be5*;e&L1ehF>wF33Dg_A3=oo&Bk$=SkNn4yPoRe>Eb#`>P
z!s=+Kvt=vahIzh6z+Q$Gt=%0xon7IC&Ndee;AT1nFj6hwz
z($^Ev=R>65IyIOv^}I2AT*r9xW?fEaDr?zb*2ch;8~Zm6H~4SbaIS8{!5RK3H~JF%
zSsTymvO;?E{>I+M5q(}rpSgco@3K%~NyuIlvR4esv2CZTgHsmxE9(5V`pXHj-1IAq
z%#bmzW(>CeTBy^v3ydc}<_%U3=k?9|z_ifkU2s{!6jpyJm)g=lESS;P
zFfL^lv#VJDsqa)-?r-6O8=dDCIY*2o0}BS#r)LDF%ny_<7%|rQ@9Yd1J3|FC
z#^sD5XPf~M<4W+2>hl8ny#BTC>np|+8S^6ciiXLtfi`xVPaV=__oqHOFV-RNH9fM;
z{qYW*hYmnd%Q@YYkgeReWUPM4*~+u+{&n0)y)&4-DNyemX8YO(*n!$X_0h(2IkWur
zPG3VHd(%hug8rU?_Mp86hQX9SU=En7e03pf@xX?FwZ`jP9Ll!%8bao5U;UURuYbb_
zmZ^Qxki`z2bsTGY@~+|3fMr3SERB<@`2?3wn6z|tH0oe5#y|o)qX%e
zu41gy;A@{+{!wOjA9pCrrx?pC8`yNZb~JBp?=o0RRp$%JpKA9th3r#D?KJ_~Ag`nR
zInz;-Z`p{xAf&gB>I(zRj2xCtCX+vJynso~+^^}?jA*SPElgQJoBL?;IPft;
z*7z<7D7znJn)}uq%7V!VSxd)YdMkoCQ$dPRGoHkxo8pZy>m`ioHujq@vS5LH^F;xZ
zvzh(+$`Y2*XMe$X70J0FtzmysZxXZyHuqSzwO@DWR#*i&X|Rk_b+C+6v-{bA*7jJ^
zU&oUn|FXI3Vw!5j48>b!8KSk8fvB@KGvb_*6|=N
zM|4GHM6@aqlq5Z8i0F#VlXb=PNxGt%(^6eg4XTqGl>dwlU8mjy90D|h^q6LlA!M`{
z(|Mf)9wQLyQp(Vc&FNy2V1!nHVjvN;WaUjc3ee}Gv*OLgI|abTu007rI2E0BRA(!p0?G*t?s`T+9zDHsU`mml1+
zRAR!9=>ez#fHD9u6|+y{S%BIhcIpgXJVnr
z{GPADxEQ_XZ@?hEx|xN($(){_jd61jXzP}uZy7*1rHupa3i$r2u4bg=+xdAkx|?s?
z+|~u;JwmO+O2|jOVNGj?v!%-!4Z8RV(rtxCPvYD_w6jRs&I&~~OsVU0^4z*I;@(;Q
zU-0eH{{ybw@W0^N`GvlO{~v@~Z$j?eUp3;@J0`Jj0|VsS_JL9cVhe
zWccpD?8ZQ8(};1!g>1`Zxg^sHw;hGmz;_MyaR#IS;WOAG%ymHXzP@_gL`i=RW0?Z_
zLR~PY)|VL4T1LIvynr^ZfBlHI5`)_OfHr?bTNu(BqfGO{1QE2C0|lQ<0&_$bT1#2z
z>YvC$J6UgphR%Q{e|@EZ($D`9`&?NafNdo%M-aO4OjgmELNg>5Tp+
z-D;TQ$ZK{iN1Av*<1Ga*i``ThCvGbC=7421fz!dcQ$a5!z&LJ*yQVM*hS)WQA$m=b
z?lB1I{2Z~}YT9n3oEGFY6)|Hzy`H#UZ?3?QBekU5NteK-$1^NsM5MpgAkB!#%!d~y
z@#$i}zyNNK^%m~DZb~0sD@bDTI>0?K7h2AYJ&3D(GO1Mpsg35re84d$mTtC?
z4P>@dY%bJr7;1p2;j-y%hcrVl!*J=nLME8ebA&7y`B-V_f(0fqfwQ1oxT%^e`UL~V
zcyi)fDdhb3t&HUPK`V6vOIraqXX4O9-5jmk8m-$FX=r)e9Z3R56C;fg3yLlX*h`BT
zh-FZvgy0jfUMH=MHD)oh=)&sBitd`My*BZagwa!U|jEWLb0pd{ujVgeP7MM+Sc<$?qKq3m7stXu1!MS|
z5egCDqA!k$^Z0S&;W~*4vt>Na8jqT1vsK@8px;IN6D93m(srz3X^
z({~}z*8%it$A8buV}^DxQ}dIUIrN{J+m*cYuAVj?Z0RwQ9+p9F7vBMug_U=-xLdb0
zw{el1kT{N>*wNPI6#2QV1x)qfWLI|&-|CDo`mQ)?ZUa%1sJYcjTGkoK7Z1!%_&JjE
z`($7VH>UlFA$<9sk{^)rNw1_rI`jTHy>t3aL0#Fz+Z6E)#Q)w_zKr!ojpe8HXZ0hd
z#_Ji(vu*wP&s02FaoRS#(!csvu=eGy^{;OZX5a2ppU<$x?75;jcThi^@4snNz_xir
zzXc4NlkL#d#=h0wIzD53g
zct|v3L|ZM!)E!xTU~&Hzf5p5&!Hq$4?TB{%SY}S&_9Map!C$)UY)jCx;)BeUzS@x9
zxPNKy(tgA)g?RehkeAy~BVtSwNqG+z%4=mX*(tL-%byKi;^SLFP2V8?UoZb|eR)4Od!Qa^(G;Q_Ooi|xv
z5HI8w`%7kxgiN;nH3#A0T;Zs>JYXEE>%6o~}H-_ViMJ#i9}OVo(k+eR9
zf&95J%%RfhgUx}``r%E1(&c^2$MUMd;O=+wfpxBtyLb3^dV+WFI=61uX(^})p1|xi
z=Vsmv7KM7rLdlm5lfOvPS0Afupl8Hf6_ei{$gc^QY9Q4cY03%L;LOuY0@D_rRRpFr
z1uIsJm{($MqkYs^955CSOnu*24yI}QoN+T_$czl=GpZ+510_N0j1hfxNDo%(-p1a=
z<0Vx4C}%7)2HS^={7W|k=5Gw<+~!Lh)9QWegIe3DwlttE9a#O`+M{a+z3$`9!`uAY
z(h+UbPu4`1Q%&UdpQwrKU>VcRU;^KQCUEZ;%>PIUnFn8;*O=PkCbi`Ck}AMAtg0rt
z;*DY%VtFkCQEyf(oB_^zayg<}wStwur?WTBmcKV!4*pg&29bT*RPs*`ew~F!R!<7R
zCoepA@s-^ZciVp^<9kbW!;nJlB4GtwGH-Ued4^OxG*$AgWT^F_Qr0wz>E{vN25`Yq
zF!5^m*uxbDC0u*+f57y)2xAC;B!7N*rb2-!7f9(NFT(rYj~}UJlZeCjrlJE+lP+@A
zH;+vtRlOZbjDQDb!{li1a@IKXm)>y+`&;tK-{vab0bx6h%+`O};Gc4J52PiKR<|xb8qVVYJM6~`m68R=<;v$5X
z5dINiH?ht!D8e$lfF>XB%e`e7V#Dg_(a^(p@b_S~C?TB0J6+u!cY`$t;=^zhxwdw9
z!8NUHQ(K3Vhx?D{eX0sXSgp55j*!Sh>rS%av0~OV^uS~DSud>}&(g4hce;D{uCT1J
zyUPi!{RPO_sp@ERxz|IFHb!HFBh>1a@&__brSF|leM$p+$!xx
z=ME`R9?WH5Bqaw&;o%$@RN1f!9!a?1F+uAVSn7sDmFx_%@X)P^n9BPQy2!CZ(-bwh
zMF{CQyl_%d)!|CT>>p2h<#4rP-j63Of4oL9Ka#Zf@n@8D;^lk|nf1in)GnwKBgDPi
zOMdZ$jjbO#@x*Ld=_!a`%?ps+;7AB7I-Tw<-CSfc>5F<9K4ai_VY59%_tdkE_oDYc
z0EdBYq_FmmmexCgDmLSL1>6>qos!lJ+mQpf3!ag?oA2o6c0|mrXqFAjJG!^QlZKax
zF$=RpL!ecqf
zzr@N6k+pw>@W*D!DUl|>KIWJ{i1JS%VC(tk(Km#EZFWQ+$l})`(tG-}RUdzp|@L)UNP(db5e5NifM8GKgneFxhzvBzkd=Gr}W6f-@Funz&Bc<=`h4k7d+
zJb`co;TXcZ2-j>KeB;X_OvA>_g2wG;$b#qQNgifN=|SVr;paB6CA6yfkOm)Z9mJi@Z0K57T*dXpxsTl#VuS?R45?gvm3&
z{~l{lTssYCFekQ+{}ADt;q_x2LN5+)D&*bGd?``Nr+ler<>o62sXR{|`H*2I^0_K~
zXzW)(mi>qf{-!qR62$FHE-S-LO8mdcA{j5(6z`+ROJwc~<%)|B?Z34DmxO;|jr?CB
z_LoDydtr`5_AJywlrLJmXf1&KhDl*Hyi0-|4x7#unUtB>?vn_ZpMMSk-qylwAK}T_UPdXq
z5&i~qKMN36uiD{qJ3H&Qx4B`9&;~>|e&0*Q^2?Cr40-FN$JnBw+g>hZS;^3mSF+gk
zcvOWI?XKpQt!;d)g<&N;L!)o99BH&IkzOGuqr)G+8ZnMeSOfa7o=D{{68*3(F(#7#
zklZ-jp#Bch-@}f5NPNRC*)2hJHc39?nyc3@n9aO4+fbJ(4J(_Q@ts$5bMzdKyuFLA
z7g`HlN(-^R$f6>zoH4S$Afsn2fPXn-siucJromrWY`}cqW4m1FTZuk*%tv!#9Q|A@
zf3-esf*sH=1%5@Ie${5CJ4zAx0}Wd1Qz#>nnSA)F4r;#os>Mv@;X(kWKqn#6Lag*b
zGUc@l!^h}F>kWS&!Wj&5WW#G&>?qmx+OD}cw_)A#Zmy@p+1TxFfai-`JI1Gl*BZQVIX&d`V{x`O}+CWb91`DSxZ+
FKLL(n7OemP
diff --git a/desktop/app.py b/desktop/app.py
index e2b1c52..b05d9d8 100644
--- a/desktop/app.py
+++ b/desktop/app.py
@@ -250,8 +250,8 @@ class DesktopConfig:
class DesktopApi:
- def __init__(self, app_base_url: str) -> None:
- self.app_base_url = app_base_url.rstrip("/")
+ def __init__(self, backend_base_url: str) -> None:
+ self.app_base_url = backend_base_url.rstrip("/")
self.session = requests.Session()
def select_brand_xlsx_files(self) -> list[str]:
@@ -347,17 +347,38 @@ class DesktopApi:
return {"success": False, "error": str(exc)}
def list_txt_templates(self) -> list[dict]:
- templates, _default_template_id = build_effective_templates()
- return [
- {
- "id": template["id"],
- "label": template["label"],
- "output_filename": template["output_filename"],
- "is_default": template.get("is_default", False),
- "built_in": template.get("built_in", False),
- }
- for template in templates.values()
- ]
+ try:
+ response = self.session.get(
+ resolve_url(self.app_base_url, "/api/convert/templates"),
+ timeout=60,
+ )
+ response.raise_for_status()
+ payload = response.json()
+ if not payload.get("success"):
+ return []
+ items = payload.get("data") or []
+ return [
+ {
+ "id": str(item.get("templateCode") or item.get("id") or ""),
+ "label": item.get("templateName") or "",
+ "output_filename": item.get("outputFilename") or "",
+ "is_default": bool(item.get("isDefault")),
+ "built_in": bool(item.get("builtIn")),
+ }
+ for item in items
+ ]
+ except Exception:
+ templates, _default_template_id = build_effective_templates()
+ return [
+ {
+ "id": template["id"],
+ "label": template["label"],
+ "output_filename": template["output_filename"],
+ "is_default": template.get("is_default", False),
+ "built_in": template.get("built_in", False),
+ }
+ for template in templates.values()
+ ]
def import_txt_template(self, name: str | None = None) -> dict:
file_path = run_file_dialog(
@@ -370,39 +391,60 @@ class DesktopApi:
return {"success": False, "error": "用户取消"}
try:
- template_text = Path(file_path).read_text(encoding="utf-8")
- except UnicodeDecodeError:
- template_text = Path(file_path).read_text(encoding="utf-8-sig")
+ try:
+ template_text = Path(file_path).read_text(encoding="utf-8")
+ except UnicodeDecodeError:
+ template_text = Path(file_path).read_text(encoding="utf-8-sig")
+
+ response = self.session.post(
+ resolve_url(self.app_base_url, "/api/convert/templates/import"),
+ json={
+ "templateName": (name or "").strip() or Path(file_path).stem,
+ "templateContent": template_text,
+ },
+ timeout=120,
+ )
+ response.raise_for_status()
+ payload = response.json()
+ if not payload.get("success") or not payload.get("data"):
+ return {"success": False, "error": payload.get("message") or "导入失败"}
+ item = payload.get("data") or {}
+ return {
+ "success": True,
+ "template": {
+ "id": str(item.get("templateCode") or item.get("id") or ""),
+ "label": item.get("templateName") or "",
+ "output_filename": item.get("outputFilename") or "",
+ "is_default": bool(item.get("isDefault")),
+ "built_in": bool(item.get("builtIn")),
+ },
+ }
except Exception as exc: # noqa: BLE001
return {"success": False, "error": str(exc)}
- template = recognize_txt_template(template_text, (name or "").strip() or None)
- if not template:
- return {"success": False, "error": "无法识别模板"}
-
- user_templates, default_template_id = load_user_templates()
- user_templates[template["id"]] = template
- save_user_templates(user_templates, default_template_id)
-
- return {
- "success": True,
- "template": {
- "id": template["id"],
- "label": template["label"],
- "output_filename": template["output_filename"],
- "is_default": False,
- "built_in": False,
- },
- }
-
def set_default_txt_template(self, template_id: str) -> dict:
- templates, _default_template_id = build_effective_templates()
- if template_id not in templates:
- return {"success": False, "error": "模板不存在"}
+ try:
+ response = self.session.post(
+ resolve_url(self.app_base_url, f"/api/convert/templates/{template_id}/default"),
+ timeout=60,
+ )
+ response.raise_for_status()
+ payload = response.json()
+ return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None}
+ except Exception as exc: # noqa: BLE001
+ return {"success": False, "error": str(exc)}
- user_templates, _ = load_user_templates()
- save_user_templates(user_templates, template_id)
- return {"success": True}
+ def delete_txt_template(self, template_id: str) -> dict:
+ try:
+ response = self.session.delete(
+ resolve_url(self.app_base_url, f"/api/convert/templates/{template_id}"),
+ timeout=60,
+ )
+ response.raise_for_status()
+ payload = response.json()
+ return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None}
+ except Exception as exc: # noqa: BLE001
+ return {"success": False, "error": str(exc)}
def split_excel_files(self, payload: dict) -> dict:
paths = payload.get("paths") or []
@@ -410,7 +452,6 @@ class DesktopApi:
split_mode = payload.get("split_mode") or "rows_per_file"
rows_per_file = payload.get("rows_per_file")
parts = payload.get("parts")
- output_dir = payload.get("output_dir") or ""
if not isinstance(paths, list) or not paths:
return {"success": False, "error": "未选择待拆分文件"}
@@ -422,128 +463,171 @@ class DesktopApi:
return {"success": False, "error": "每份条数不合法"}
if split_mode == "parts" and (not isinstance(parts, int) or parts <= 0):
return {"success": False, "error": "拆分份数不合法"}
- if not output_dir:
- return {"success": False, "error": "未选择输出目录"}
- output_dir_path = Path(output_dir)
- if not output_dir_path.exists() or not output_dir_path.is_dir():
- return {"success": False, "error": "输出目录不存在"}
-
- items: list[dict] = []
- success_count = 0
- failed_count = 0
-
- for path in paths:
- try:
- workbook = load_workbook(path)
- sheet = workbook[workbook.sheetnames[0]]
- header_cells = next(sheet.iter_rows(min_row=1, max_row=1))
- header_map: dict[str, int] = {}
- for index, cell in enumerate(header_cells):
- value = normalize_cell_text(cell.value)
- if not value:
- continue
- value = value.split("idASIN国家状态价格变体数量", 1)[0].strip() or value
- if value in header_map:
- continue
- header_map[value] = index
- if value == "缩略图地址8":
- break
-
- missing = [column for column in selected_columns if column not in header_map]
- if missing:
- workbook.close()
- items.append({
- "source_path": path,
- "success": False,
- "error": f"缺少列:{'、'.join(missing)}",
- })
- failed_count += 1
- continue
-
- rows_data: list[list[str]] = []
- for row in sheet.iter_rows(min_row=2, values_only=True):
- projected = [
- normalize_cell_text(row[header_map[column]]) if header_map[column] < len(row) else ""
- for column in selected_columns
- ]
- rows_data.append(projected)
-
- total_rows = len(rows_data)
- if total_rows == 0:
- workbook.close()
- items.append({
- "source_path": path,
- "success": False,
- "error": "没有可拆分的数据行",
- })
- failed_count += 1
- continue
-
- if split_mode == "rows_per_file":
- chunk_size = rows_per_file
- chunks = [rows_data[i : i + chunk_size] for i in range(0, total_rows, chunk_size)]
- else:
- actual_parts = min(parts, total_rows)
- base = total_rows // actual_parts
- remainder = total_rows % actual_parts
- chunks = []
- start = 0
- for idx in range(actual_parts):
- size = base + (1 if idx < remainder else 0)
- end = start + size
- chunks.append(rows_data[start:end])
- start = end
-
- from openpyxl import Workbook
-
- source = Path(path)
- for index, chunk in enumerate(chunks, start=1):
- output_workbook = Workbook()
- output_sheet = output_workbook.active
- output_sheet.title = sheet.title
- output_sheet.append(selected_columns)
- for row_values in chunk:
- output_sheet.append(row_values)
-
- output_path = build_named_output_path(output_dir, f"{source.stem}_split_{index}.xlsx")
- output_workbook.save(output_path)
- output_workbook.close()
-
- items.append({
- "source_path": path,
- "output_path": output_path,
- "success": True,
- "row_count": len(chunk),
- })
-
- workbook.close()
- success_count += 1
- except Exception as exc: # noqa: BLE001
- items.append({
- "source_path": path,
- "success": False,
- "error": str(exc),
+ try:
+ uploaded_files = []
+ for path in paths:
+ with open(path, "rb") as file_obj:
+ response = self.session.post(
+ resolve_url(self.app_base_url, "/api/files/upload"),
+ files={"file": (Path(path).name, file_obj)},
+ timeout=180,
+ )
+ response.raise_for_status()
+ upload_payload = response.json()
+ if not upload_payload.get("success") or not upload_payload.get("data"):
+ return {"success": False, "error": upload_payload.get("message") or "上传文件失败"}
+ upload_data = upload_payload.get("data") or {}
+ uploaded_files.append({
+ "fileKey": upload_data.get("fileKey"),
+ "originalFilename": upload_data.get("originalFilename") or Path(path).name,
})
- failed_count += 1
- return {
- "success": True,
- "summary": {
- "total": len(paths),
- "success_count": success_count,
- "failed_count": failed_count,
- "output_dir": str(output_dir_path),
- },
- "items": items,
- }
+ run_response = self.session.post(
+ resolve_url(self.app_base_url, "/api/split/run"),
+ json={
+ "files": uploaded_files,
+ "selectedColumns": selected_columns,
+ "splitMode": split_mode,
+ "rowsPerFile": rows_per_file,
+ "parts": parts,
+ },
+ timeout=600,
+ )
+ run_response.raise_for_status()
+ run_payload = run_response.json()
+ if not run_payload.get("success") or not run_payload.get("data"):
+ return {"success": False, "error": run_payload.get("message") or "数据拆分失败"}
+
+ data = run_payload.get("data") or {}
+ result_items = []
+ for item in data.get("items") or []:
+ if item.get("success") and item.get("downloadUrl"):
+ result_items.append({
+ "result_id": item.get("resultId"),
+ "source_path": item.get("sourceFilename") or "",
+ "output_path": item.get("downloadUrl") or "",
+ "success": True,
+ "row_count": item.get("rowCount"),
+ })
+ else:
+ result_items.append({
+ "source_path": item.get("sourceFilename") or "",
+ "success": False,
+ "error": item.get("error") or "数据拆分失败",
+ })
+
+ return {
+ "success": True,
+ "summary": {
+ "total": data.get("total") or len(paths),
+ "success_count": data.get("successCount") or 0,
+ "failed_count": data.get("failedCount") or 0,
+ },
+ "items": result_items,
+ }
+ except Exception as exc: # noqa: BLE001
+ return {"success": False, "error": str(exc)}
+
+ def delete_history_item(self, module_type: str, result_id: int) -> dict:
+ try:
+ response = self.session.delete(
+ resolve_url(self.app_base_url, f"/api/{module_type}/history/{result_id}"),
+ timeout=60,
+ )
+ response.raise_for_status()
+ payload = response.json()
+ return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None}
+ except Exception as exc: # noqa: BLE001
+ return {"success": False, "error": str(exc)}
+
+ def get_convert_history(self) -> dict:
+ try:
+ response = self.session.get(
+ resolve_url(self.app_base_url, "/api/convert/history"),
+ timeout=60,
+ )
+ response.raise_for_status()
+ payload = response.json()
+ if not payload.get("success") or not payload.get("data"):
+ return {"success": False, "error": payload.get("message") or "获取历史失败"}
+
+ items = payload.get("data", {}).get("items") or []
+ return {
+ "success": True,
+ "items": [
+ {
+ "result_id": item.get("resultId"),
+ "source_path": item.get("sourceFilename") or "",
+ "output_path": item.get("downloadUrl") or "",
+ "success": bool(item.get("success", True)),
+ }
+ for item in items
+ ],
+ }
+ except Exception as exc: # noqa: BLE001
+ return {"success": False, "error": str(exc)}
+
+ def get_split_history(self) -> dict:
+ try:
+ response = self.session.get(
+ resolve_url(self.app_base_url, "/api/split/history"),
+ timeout=60,
+ )
+ response.raise_for_status()
+ payload = response.json()
+ if not payload.get("success") or not payload.get("data"):
+ return {"success": False, "error": payload.get("message") or "获取历史失败"}
+
+ items = payload.get("data", {}).get("items") or []
+ return {
+ "success": True,
+ "items": [
+ {
+ "result_id": item.get("resultId"),
+ "source_path": item.get("sourceFilename") or "",
+ "output_path": item.get("downloadUrl") or "",
+ "success": bool(item.get("success", True)),
+ "row_count": item.get("rowCount"),
+ }
+ for item in items
+ ],
+ }
+ except Exception as exc: # noqa: BLE001
+ return {"success": False, "error": str(exc)}
+
+ def get_dedupe_history(self) -> dict:
+ try:
+ response = self.session.get(
+ resolve_url(self.app_base_url, "/api/dedupe/history"),
+ timeout=60,
+ )
+ response.raise_for_status()
+ payload = response.json()
+ if not payload.get("success") or not payload.get("data"):
+ return {"success": False, "error": payload.get("message") or "获取历史失败"}
+
+ items = payload.get("data", {}).get("items") or []
+ return {
+ "success": True,
+ "items": [
+ {
+ "result_id": item.get("resultId"),
+ "source_path": item.get("sourceFilename") or "",
+ "output_path": item.get("downloadUrl") or "",
+ "success": bool(item.get("success", True)),
+ }
+ for item in items
+ ],
+ }
+ except Exception as exc: # noqa: BLE001
+ return {"success": False, "error": str(exc)}
def clean_excel_files(self, payload: dict) -> dict:
paths = payload.get("paths") or []
selected_columns = payload.get("selected_columns") or []
keep_integer_ids = bool(payload.get("keep_integer_ids", True))
keep_underscore_ids = bool(payload.get("keep_underscore_ids", True))
- output_dir = payload.get("output_dir") or ""
if not isinstance(paths, list) or not paths:
return {"success": False, "error": "未选择待处理文件"}
@@ -551,213 +635,140 @@ class DesktopApi:
return {"success": False, "error": "请至少选择一列"}
if not keep_integer_ids and not keep_underscore_ids:
return {"success": False, "error": "请至少选择一种 ID 保留规则"}
- if not output_dir:
- return {"success": False, "error": "未选择输出目录"}
- output_dir_path = Path(output_dir)
- if not output_dir_path.exists() or not output_dir_path.is_dir():
- return {"success": False, "error": "输出目录不存在"}
+ try:
+ uploaded_files = []
+ for path in paths:
+ with open(path, "rb") as file_obj:
+ response = self.session.post(
+ resolve_url(self.app_base_url, "/api/files/upload"),
+ files={"file": (Path(path).name, file_obj)},
+ timeout=180,
+ )
+ response.raise_for_status()
+ upload_payload = response.json()
+ if not upload_payload.get("success") or not upload_payload.get("data"):
+ return {"success": False, "error": upload_payload.get("message") or "上传文件失败"}
+ upload_data = upload_payload.get("data") or {}
+ uploaded_files.append({
+ "fileKey": upload_data.get("fileKey"),
+ "originalFilename": upload_data.get("originalFilename") or Path(path).name,
+ })
- items: list[dict] = []
- success_count = 0
- failed_count = 0
+ run_response = self.session.post(
+ resolve_url(self.app_base_url, "/api/dedupe/run"),
+ json={
+ "files": uploaded_files,
+ "selectedColumns": selected_columns,
+ "keepIntegerIds": keep_integer_ids,
+ "keepUnderscoreIds": keep_underscore_ids,
+ },
+ timeout=600,
+ )
+ run_response.raise_for_status()
+ run_payload = run_response.json()
+ if not run_payload.get("success") or not run_payload.get("data"):
+ return {"success": False, "error": run_payload.get("message") or "去重失败"}
- for path in paths:
- try:
- workbook = load_workbook(path)
- sheet = workbook[workbook.sheetnames[0]]
- header_cells = next(sheet.iter_rows(min_row=1, max_row=1))
- headers: list[str] = []
- header_map: dict[str, int] = {}
- for index, cell in enumerate(header_cells):
- value = normalize_cell_text(cell.value)
- if not value:
- continue
- value = value.split("idASIN国家状态价格变体数量", 1)[0].strip() or value
- if value in header_map:
- continue
- header_map[value] = index
- headers.append(value)
- if value == "缩略图地址8":
- break
-
- missing = [column for column in selected_columns if column not in header_map]
- if missing:
- workbook.close()
- items.append({
- "source_path": path,
- "success": False,
- "error": f"缺少列:{'、'.join(missing)}",
+ data = run_payload.get("data") or {}
+ result_items = []
+ for item in data.get("items") or []:
+ if item.get("success") and item.get("downloadUrl"):
+ result_items.append({
+ "source_path": item.get("sourceFilename") or "",
+ "output_path": item.get("downloadUrl") or "",
+ "success": True,
+ })
+ else:
+ result_items.append({
+ "source_path": item.get("sourceFilename") or "",
+ "success": False,
+ "error": item.get("error") or "去重失败",
})
- failed_count += 1
- continue
- from openpyxl import Workbook
-
- output_workbook = Workbook()
- output_sheet = output_workbook.active
- output_sheet.title = sheet.title
- output_sheet.append(selected_columns)
-
- id_column_index = header_map.get("id")
-
- for row in sheet.iter_rows(min_row=2, values_only=True):
- original_values = list(row)
- if id_column_index is not None:
- id_value = original_values[id_column_index] if id_column_index < len(original_values) else None
- if not should_keep_id(id_value, keep_integer_ids, keep_underscore_ids):
- continue
-
- cleaned_values = [
- normalize_cell_text(original_values[header_map[column]])
- if header_map[column] < len(original_values)
- else ""
- for column in selected_columns
- ]
- output_sheet.append(cleaned_values)
-
- output_path = build_output_path(output_dir, path)
- output_workbook.save(output_path)
- output_workbook.close()
- workbook.close()
-
- items.append({
- "source_path": path,
- "output_path": output_path,
- "success": True,
- })
- success_count += 1
- except Exception as exc: # noqa: BLE001
- items.append({
- "source_path": path,
- "success": False,
- "error": str(exc),
- })
- failed_count += 1
-
- return {
- "success": True,
- "summary": {
- "total": len(paths),
- "success_count": success_count,
- "failed_count": failed_count,
- "output_dir": str(output_dir_path),
- },
- "items": items,
- }
+ return {
+ "success": True,
+ "summary": {
+ "total": data.get("total") or len(paths),
+ "success_count": data.get("successCount") or 0,
+ "failed_count": data.get("failedCount") or 0,
+ },
+ "items": result_items,
+ }
+ except Exception as exc: # noqa: BLE001
+ return {"success": False, "error": str(exc)}
def convert_excel_to_uk_txt(self, payload: dict) -> dict:
paths = payload.get("paths") or []
- output_dir = payload.get("output_dir") or ""
template_id = payload.get("template_id") or "uk_offer"
if not isinstance(paths, list) or not paths:
return {"success": False, "error": "未选择待转换文件"}
- if not output_dir:
- return {"success": False, "error": "未选择输出目录"}
- templates, _default_template_id = build_effective_templates()
- template = templates.get(template_id)
- if not template:
- return {"success": False, "error": "模板不存在"}
+ try:
+ templates = self.list_txt_templates()
+ selected_template = next((item for item in templates if item.get("id") == template_id), None)
+ if not selected_template:
+ return {"success": False, "error": "模板不存在"}
- output_dir_path = Path(output_dir)
- if not output_dir_path.exists() or not output_dir_path.is_dir():
- return {"success": False, "error": "输出目录不存在"}
+ uploaded_files = []
+ for path in paths:
+ with open(path, "rb") as file_obj:
+ response = self.session.post(
+ resolve_url(self.app_base_url, "/api/files/upload"),
+ files={"file": (Path(path).name, file_obj)},
+ timeout=180,
+ )
+ response.raise_for_status()
+ upload_payload = response.json()
+ if not upload_payload.get("success") or not upload_payload.get("data"):
+ return {"success": False, "error": upload_payload.get("message") or "上传文件失败"}
+ upload_data = upload_payload.get("data") or {}
+ uploaded_files.append({
+ "fileKey": upload_data.get("fileKey"),
+ "originalFilename": upload_data.get("originalFilename") or Path(path).name,
+ })
- items: list[dict] = []
- success_count = 0
- failed_count = 0
+ run_response = self.session.post(
+ resolve_url(self.app_base_url, "/api/convert/run"),
+ json={
+ "files": uploaded_files,
+ "templateId": str(template_id),
+ },
+ timeout=600,
+ )
+ run_response.raise_for_status()
+ run_payload = run_response.json()
+ if not run_payload.get("success") or not run_payload.get("data"):
+ return {"success": False, "error": run_payload.get("message") or "格式转换失败"}
- for path in paths:
- try:
- workbook = load_workbook(path, read_only=True, data_only=True)
- sheet = workbook[workbook.sheetnames[0]]
- header_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
- if not header_row:
- workbook.close()
- items.append({"source_path": path, "success": False, "error": 'Excel 表头为空'})
- failed_count += 1
- continue
-
- header_map: dict[str, int] = {}
- for index, cell in enumerate(header_row):
- value = normalize_cell_text(cell)
- if not value or value in header_map:
- continue
- header_map[value] = index
-
- required = template["required_source_columns"]
- missing = [column for column in required if column not in header_map]
- if missing:
- workbook.close()
- items.append({
- "source_path": path,
- "success": False,
- "error": f"缺少列:{'、'.join(missing)}",
+ data = run_payload.get("data") or {}
+ result_items = []
+ for item in data.get("items") or []:
+ if item.get("success") and item.get("downloadUrl"):
+ result_items.append({
+ "source_path": item.get("sourceFilename") or "",
+ "output_path": item.get("downloadUrl") or "",
+ "success": True,
+ })
+ else:
+ result_items.append({
+ "source_path": item.get("sourceFilename") or "",
+ "success": False,
+ "error": item.get("error") or "格式转换失败",
})
- failed_count += 1
- continue
- rows: list[str] = []
- rows.extend(template["preamble_lines"])
- rows.append('\t'.join(template["header_columns"]))
- for _ in range(template.get("blank_header_rows_after_schema", 0)):
- rows.append('\t' * (len(template["header_columns"]) - 1))
-
- batch_id = generate_snowflake_like_id()
- row_index = 1
- for row in sheet.iter_rows(min_row=2, values_only=True):
- line_values: list[str] = []
- asin = normalize_cell_text(row[header_map['ASIN']] if header_map['ASIN'] < len(row) else '')
- if not asin:
- continue
-
- for column in template["header_columns"]:
- if column == 'sku':
- line_values.append(f'{batch_id}-{row_index}')
- continue
- if column in template["field_mapping"]:
- source_column = template["field_mapping"][column]
- value = normalize_cell_text(row[header_map[source_column]] if header_map[source_column] < len(row) else '')
- line_values.append(value)
- continue
- if column in template["defaults"]:
- line_values.append(str(template["defaults"][column]))
- continue
- line_values.append('')
-
- rows.append('\t'.join(line_values))
- row_index += 1
-
- output_path = build_named_output_path(output_dir, template["output_filename"])
- Path(output_path).write_text('\n'.join(rows), encoding='utf-8')
- workbook.close()
-
- items.append({
- "source_path": path,
- "output_path": output_path,
- "success": True,
- })
- success_count += 1
- except Exception as exc: # noqa: BLE001
- items.append({
- "source_path": path,
- "success": False,
- "error": str(exc),
- })
- failed_count += 1
-
- return {
- "success": True,
- "summary": {
- "total": len(paths),
- "success_count": success_count,
- "failed_count": failed_count,
- "output_dir": str(output_dir_path),
- },
- "items": items,
- }
+ return {
+ "success": True,
+ "summary": {
+ "total": data.get("total") or len(paths),
+ "success_count": data.get("successCount") or 0,
+ "failed_count": data.get("failedCount") or 0,
+ },
+ "items": result_items,
+ }
+ except Exception as exc: # noqa: BLE001
+ return {"success": False, "error": str(exc)}
def open_path(self, path: str) -> dict:
try:
@@ -939,7 +950,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--port", type=int, default=0)
parser.add_argument("--width", type=int, default=1440)
parser.add_argument("--height", type=int, default=960)
- parser.add_argument("--title", default="南日AI")
+ parser.add_argument("--title", default="数富AI")
return parser
@@ -985,7 +996,7 @@ def main() -> None:
ensure_dev_server_ready(config.frontend_url)
app_url = config.frontend_url
- api = DesktopApi(app_url)
+ api = DesktopApi(config.backend_url)
window = webview.create_window(
title=config.title,
url=app_url,
diff --git a/desktop/run_desktop_dev.ps1 b/desktop/run_desktop_dev.ps1
index 9610368..eda8ff8 100644
--- a/desktop/run_desktop_dev.ps1
+++ b/desktop/run_desktop_dev.ps1
@@ -64,4 +64,4 @@ Write-Host "Ensuring desktop dependencies..."
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt")
Write-Host "Starting desktop app..."
-Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dev", "--frontend-url", "http://127.0.0.1:5173", "--backend-url", "http://127.0.0.1:8000")
+Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dev", "--frontend-url", "http://127.0.0.1:5173", "--backend-url", "http://127.0.0.1:18080")
diff --git a/desktop/txt_templates.json b/desktop/txt_templates.json
index 8873222..da5deed 100644
--- a/desktop/txt_templates.json
+++ b/desktop/txt_templates.json
@@ -90,6 +90,51 @@
"price": "价格",
"product-id": "ASIN"
}
+ },
+ "user_1773982097249": {
+ "id": "user_1773982097249",
+ "label": "默认",
+ "output_filename": "默认.txt",
+ "is_default": false,
+ "built_in": false,
+ "preamble_lines": [
+ "TemplateType=Offer\tVersion=1.4\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t"
+ ],
+ "header_columns": [
+ "sku",
+ "price",
+ "quantity",
+ "product-id",
+ "product-id-type",
+ "condition-type",
+ "condition-note",
+ "ASIN-hint",
+ "title",
+ "product-tax-code",
+ "operation-type",
+ "sale-price",
+ "sale-start-date",
+ "sale-end-date",
+ "leadtime-to-ship",
+ "launch-date",
+ "is-giftwrap-available",
+ "is-gift-message-available"
+ ],
+ "blank_header_rows_after_schema": 1,
+ "required_source_columns": [
+ "ASIN",
+ "价格"
+ ],
+ "defaults": {
+ "quantity": "100",
+ "product-id-type": "ASIN",
+ "condition-type": "new",
+ "leadtime-to-ship": "4"
+ },
+ "field_mapping": {
+ "price": "价格",
+ "product-id": "ASIN"
+ }
}
}
}
\ No newline at end of file
diff --git a/frontend-vue/index.html b/frontend-vue/index.html
index ab51280..707eae4 100644
--- a/frontend-vue/index.html
+++ b/frontend-vue/index.html
@@ -3,7 +3,7 @@
- 南日AI
+ 数富AI
diff --git a/frontend-vue/src/pages/brand/components/BrandConvertTab.vue b/frontend-vue/src/pages/brand/components/BrandConvertTab.vue
index cfc2de6..8bbda4b 100644
--- a/frontend-vue/src/pages/brand/components/BrandConvertTab.vue
+++ b/frontend-vue/src/pages/brand/components/BrandConvertTab.vue
@@ -41,13 +41,23 @@
:class="{ active: convertTemplateId === template.id }"
>
-
-
- {{ template.label }}
- 默认
- 内置
-
-
输出文件:{{ template.output_filename }}
+
+
+
+ {{ template.label }}
+ 默认
+ 内置
+
+
输出文件:{{ template.output_filename }}
+
+
@@ -81,7 +91,7 @@
{{ convertRunning ? '转换中...' : '开始转换' }}
- {{ convertRunning ? '正在生成英国.txt,请稍候…' : '选择文件后即可执行格式转换' }}
+ {{ convertRunning ? '正在生成并上传结果,请稍候…' : '选择文件后即可执行格式转换,结果会显示在右侧供下载' }}
@@ -108,9 +118,6 @@
@@ -120,12 +127,12 @@
-
-
{{ shorten(item.source_path, 42) }}
-
输出文件:{{ item.output_path }}
+
{{ item.source_path || '-' }}
+
结果下载地址已生成
错误信息:{{ item.error }}
@@ -137,9 +144,17 @@
v-if="item.success && item.output_path"
type="button"
class="download"
- @click="openConvertResult(item)"
+ @click="downloadConvertResult(item)"
>
- 打开文件
+ 下载文件
+
+
@@ -175,7 +190,6 @@ function shorten(value: string, maxLength: number) {
}
function resetConvertResults() {
- convertResultItems.value = []
convertSummary.value = { total: 0, success_count: 0, failed_count: 0 }
convertOutputDir.value = ''
}
@@ -217,6 +231,26 @@ async function importConvertTemplate() {
ElMessage.success('模板导入成功')
}
+async function deleteConvertTemplate(templateId: string) {
+ const api = getPywebviewApi()
+ if (!api?.delete_txt_template) {
+ ElMessage.warning('当前环境不支持删除模板')
+ return
+ }
+
+ const result = await api.delete_txt_template(templateId)
+ if (!result.success) {
+ ElMessage.error(result.error || '删除模板失败')
+ return
+ }
+
+ if (convertTemplateId.value === templateId) {
+ convertTemplateId.value = ''
+ }
+ await loadConvertTemplates()
+ ElMessage.success('模板已删除')
+}
+
async function setConvertDefaultTemplate() {
const api = getPywebviewApi()
if (!api?.set_default_txt_template || !convertTemplateId.value) {
@@ -279,7 +313,7 @@ async function selectConvertFolder() {
async function submitConvertRun() {
const api = getPywebviewApi()
- if (!api?.convert_excel_to_uk_txt || !api?.select_folder) {
+ if (!api?.convert_excel_to_uk_txt) {
ElMessage.warning('当前环境不支持格式转换,请在桌面端打开')
return
}
@@ -289,13 +323,9 @@ async function submitConvertRun() {
}
try {
- const outputDir = await api.select_folder()
- if (!outputDir) return
-
convertRunning.value = true
const result = await api.convert_excel_to_uk_txt({
paths: convertSelectedPaths.value,
- output_dir: outputDir,
template_id: convertTemplateId.value,
})
@@ -304,13 +334,13 @@ async function submitConvertRun() {
return
}
- convertOutputDir.value = result.summary?.output_dir || outputDir
+ convertOutputDir.value = ''
convertSummary.value = {
total: result.summary?.total || 0,
success_count: result.summary?.success_count || 0,
failed_count: result.summary?.failed_count || 0,
}
- convertResultItems.value = result.items || []
+ await loadConvertHistory()
ElMessage.success('格式转换完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '格式转换失败')
@@ -319,34 +349,68 @@ async function submitConvertRun() {
}
}
-async function openConvertOutputDir() {
+async function deleteConvertHistory(resultId: number) {
const api = getPywebviewApi()
- if (!api?.open_path || !convertOutputDir.value) {
- ElMessage.warning('当前环境不支持打开输出目录')
+ if (!api?.delete_history_item) {
+ ElMessage.warning('当前环境不支持删除历史')
return
}
- const result = await api.open_path(convertOutputDir.value)
+ const result = await api.delete_history_item('convert', resultId)
if (!result.success) {
- ElMessage.error(result.error || '打开目录失败')
+ ElMessage.error(result.error || '删除失败')
+ return
}
+ await loadConvertHistory()
+ ElMessage.success('已删除')
}
-async function openConvertResult(item: ConvertTxtResultItem) {
+async function downloadConvertResult(item: ConvertTxtResultItem) {
const api = getPywebviewApi()
- if (!api?.open_path || !item.output_path) {
- ElMessage.warning('当前环境不支持打开结果文件')
+ if (!item.output_path) {
+ ElMessage.warning('当前结果没有下载地址')
return
}
- const result = await api.open_path(item.output_path)
- if (!result.success) {
- ElMessage.error(result.error || '打开文件失败')
+ const today = new Date()
+ const yyyy = String(today.getFullYear())
+ const mm = String(today.getMonth() + 1).padStart(2, '0')
+ const dd = String(today.getDate()).padStart(2, '0')
+ const filename = `${yyyy}${mm}${dd}.txt`
+ if (api?.save_file_from_url) {
+ const result = await api.save_file_from_url(item.output_path, filename)
+ if (result.success) {
+ ElMessage.success(`已保存:${result.path || filename}`)
+ } else if (result.error && result.error !== '用户取消') {
+ ElMessage.error(result.error)
+ }
+ return
+ }
+
+ window.open(item.output_path, '_blank')
+}
+
+async function loadConvertHistory() {
+ const api = getPywebviewApi()
+ if (!api?.get_convert_history) return
+
+ try {
+ const response = await api.get_convert_history()
+ if (!response.success || !response.items) return
+ convertResultItems.value = response.items
+ convertSummary.value = {
+ total: response.items.length,
+ success_count: response.items.filter((item) => item.success).length,
+ failed_count: response.items.filter((item) => !item.success).length,
+ }
+ } catch {
+ // ignore history load errors
}
}
onMounted(() => {
loadConvertTemplates().catch(() => undefined)
+ loadConvertHistory().catch(() => undefined)
})
@@ -374,7 +438,10 @@ onMounted(() => {
.template-tag { margin-left: 8px; padding: 2px 8px; border-radius: 999px; font-size: 11px; color: #8fd0ff; background: rgba(52, 152, 219, 0.14); }
.template-tag.muted { color: #b8b8b8; background: rgba(255, 255, 255, 0.08); }
.compact-row { margin-top: 4px; }
-.radio-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; background: #252525; border: 1px solid #2a2a2a; margin-bottom: 8px; transition: all 0.2s ease; }
+ .radio-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; background: #252525; border: 1px solid #2a2a2a; margin-bottom: 8px; transition: all 0.2s ease; }
+.template-item-content { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; }
+.template-delete-btn { padding: 6px 10px; border: 1px solid #5c2f2f; border-radius: 6px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; font-size: 12px; cursor: pointer; }
+.template-delete-btn:hover { background: rgba(231, 76, 60, 0.2); }
.radio-item:hover, .radio-item.active { background: #2a2a2a; border-color: #3a3a3a; }
.radio-item input { margin-top: 3px; }
.label { font-weight: 500; color: #e0e0e0; font-size: 13px; }
diff --git a/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue b/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue
index 5c35ef3..a6d83c8 100644
--- a/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue
+++ b/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue
@@ -79,7 +79,7 @@
{{ cleanRunning ? '清洗中...' : '开始清洗' }}
- {{ cleanRunning ? '正在处理文件,请稍候…' : '选择文件、列和 ID 规则后即可执行本地清洗' }}
+ {{ cleanRunning ? '正在处理文件并上传结果,请稍候…' : '选择文件、列和 ID 规则后即可执行清洗,结果会显示在右侧供下载' }}
@@ -106,9 +106,6 @@
@@ -118,8 +115,8 @@
-
-
{{ shorten(item.source_path, 42) }}
-
输出文件:{{ item.output_path }}
+
{{ item.source_path || '-' }}
+
结果下载地址已生成
错误信息:{{ item.error }}
@@ -131,9 +128,17 @@
v-if="item.success && item.output_path"
type="button"
class="download"
- @click="openCleanResult(item)"
+ @click="downloadCleanResult(item)"
>
- 打开文件
+ 下载文件
+
+
@@ -145,7 +150,7 @@
diff --git a/frontend-vue/src/pages/brand/components/BrandSplitTab.vue b/frontend-vue/src/pages/brand/components/BrandSplitTab.vue
index 93c3382..b1debca 100644
--- a/frontend-vue/src/pages/brand/components/BrandSplitTab.vue
+++ b/frontend-vue/src/pages/brand/components/BrandSplitTab.vue
@@ -90,7 +90,7 @@
{{ splitRunning ? '拆分中...' : '开始拆分' }}
- {{ splitRunning ? '正在拆分文件,请稍候…' : '选择文件、保留列和拆分规则后即可执行本地拆分' }}
+ {{ splitRunning ? '正在拆分并上传结果,请稍候…' : '选择文件、保留列和拆分规则后即可执行拆分,结果会显示在右侧供下载' }}
@@ -116,9 +116,6 @@
@@ -128,8 +125,8 @@
-
-
{{ shorten(item.source_path, 42) }}
-
输出文件:{{ item.output_path }}
+
{{ item.source_path || '-' }}
+
结果下载地址已生成
包含 {{ item.row_count }} 条数据
错误信息:{{ item.error }}
@@ -142,9 +139,17 @@
v-if="item.success && item.output_path"
type="button"
class="download"
- @click="openSplitResult(item)"
+ @click="downloadSplitResult(item)"
>
- 打开文件
+ 下载文件
+
+
@@ -156,7 +161,7 @@
diff --git a/frontend-vue/src/pages/brand/index.vue b/frontend-vue/src/pages/brand/index.vue
index aa23520..3e3f025 100644
--- a/frontend-vue/src/pages/brand/index.vue
+++ b/frontend-vue/src/pages/brand/index.vue
@@ -3,42 +3,26 @@
- 南日AI-亚马逊
+ 数富AI-亚马逊
返回首页
-
@@ -55,13 +39,8 @@
选择文件夹
-
+
@@ -117,12 +96,7 @@
{{ runMode === 'immediate' ? '立即运行' : '添加任务' }}
生成中,请稍候…
-
+
取消生成
@@ -137,8 +111,10 @@
@@ -151,9 +127,12 @@
@@ -180,10 +159,7 @@
-
+
@@ -196,21 +172,13 @@
{{ getStatusLabel(task.status) }}
-
+
取消
-
+
下载结果
@@ -560,7 +528,7 @@ async function downloadTaskResult(task: BrandTaskItem) {
}
onMounted(() => {
- document.title = '亚马逊 - 南日AI'
+ document.title = '亚马逊 - 数富AI'
loadTasks().catch(() => undefined)
taskRefreshTimer = window.setInterval(() => {
loadTasks().catch(() => undefined)
diff --git a/frontend-vue/src/pages/home/index.vue b/frontend-vue/src/pages/home/index.vue
index edba641..2dafe26 100644
--- a/frontend-vue/src/pages/home/index.vue
+++ b/frontend-vue/src/pages/home/index.vue
@@ -2,7 +2,7 @@