>tCPEAGBrvEhHYG+Nm2JMTpz
zg_ED`XTc`c8}tE1kj7WRg1>$Nosbe97T}$uPso*eA(2nstR?nR@znL0bng461#RUc
zBO{zMTp$Qb&Xm9qVip>eP5JGdQ^&~xsz%U>E;O|0cpR!nN>m$}I#SI%asp4+sitmP
zm}AO*yl~q|1QH+}I?LMMs!8gA{Q;@0#qtcyT**mThCwv}ge`k$E1tfSh7G`y1fRJi
z79f~ts)vQ-yKw#pw#OXWxV)X(Fh-b0k|Ft`kuAk?Xi_TE;DBD6Oq5E5ug-rv3
zDGM}KH$x#pvRFZ<#W15DWB-;p1LFXr>OFHHf80ic-K=_1v^M4|L%!CYx(_0#)-k8|
z3&Y5l^?|42!osB;3&Um}yBZutZ0dk>;4r?~J!E;aw)ahsYQGvi
z=*732HqkRx=`%~MMgW6eDNZtmsZ@`fSHo!)m8N>)?c%5pnofDcJI$9S#^`{a4}WS_
zF2iPPZa73kn$U@iznb_o9JX85Jrw+`J|x5AF!*h|C-Lu^B~ANb*l2NwVPo;jS$$(s
zFFNB~KaIg}#_>N@Ev){k(^kL)%JrQw{{;}gBp4sM+MC@d(n)g8aVoy)G!F|BZ?-tU&oz_5QKeZ|EQ&O)0|Jd2&RAQgg+0e-!VFYp
zsWg)arp-C7NHi?`$fXY8r^MmH2Cdv#MK?^Jb0E|v#^lP^Q8L(JS_+7^wprVPF=ZzQub*g=AuVXoLKrM^)E|m44;s0P*l^Q9!p%q&
z>CR9Ti0k!tC$?foRyTIQl9AXy%AXIDoN7lCDJ0uL?|cAzt2HJfW|B!ZJ~I#S2{TOE
zEF0>KU&lDZEv`-v65?J&YtgAJDtz$f!7fjN;!9xogDE;R=aZl|7WSue#{m!xvwb;c_O#OmH%kQc4XLp;Q#gqi&-h
zX)0f8Df~beIrjA53#v{
z=(I}V@QC$aEMohj+Ar*zE2KUFW$*}l3Tpy49&EtGx<(dN@598}^@T}%Oz*ndqb@;G
z5ej*ynLsn-HeEFVSws)hNkZ)$@ul9WQmeH2v6eMiRNlkSQd6E>A%vvABwlkSSiN)T
zy}g4_y{NL^=05x?%7mOyeHRAiiur0#n9RJkSum_8l_MHVH`%pxhA3v7bcP{jsI)Hm
zh-d&EpRW!^&Gl4-4oWy|VhH1RTP=C|NS%(6LD|(Gb=8m>O|J(BwWS5Wr?<;rXLQ=H
zm-V#PM?x60s|~kmTA+b^~5TJnv{)tu<4=AUXOF;GuI~Kay^9b3QzhWtJ;|s`D)4|Qmyo@W&v}*2*V6gk
zatUv`PKjdnzIFkqO5{h-v~i0FBx&2q@kfz9k9L78-6mn9v*)yJfw9PC(y{Csfm$dgPobjCWmU_BukSbGTK-!;a=n}enpXReMI*=s$KJ9*&+A!G}l%vc1N?D0Mhf)d)Fe~X9ePr?m6u1*d}
zAYSpzf9A}Dkc4?%^SM_>gz&7YeTi
zkMsMVB%J4oKC5%>
z%<=Sz5@mvtxT1tW^&NfFr_Oxe29yrBxsrr<(v0mF%nxj!ff5m2Ym@SLHFBBrF_(eY
z<-CW2sdyKYtso5$mV*i-(gQ!c2y#>=wqS&8$f;en@_PkDKYrT5W-+EB7U!{R|Z#baBFe
zIUb$)x5-k&lmno;V>~!jBQJIEeO$W5Po8ZJf%LHnNQVy#1EUD0Js>(#K&j+^tq~p8fDp-g&e%#Y#gq@Etjb)3dY)9B$w55Uc7T*N(SjC46K+hnn!8
zO$>WkO(8i@1DV)Bdsf=GaA)jIKgJh%-vtJpcny(f%l&*jnx`R1=D$VMOm4YD7}`p1
zI$OJp{;6}qQQR;?N5zb4QCL4)dZ4>rQ=v1%(_>vfZSFGANT)SDEG|+~aL*+RhiW6g
z%ej;Jfz%4F(*4~Ls<<&hholHYC36z2tY1WY0s>xcHNm2m&7AZ?LJMs!Fj0;1p-hl8
z4K9KOeU-zfavC&*UWSCEEnrOx*0174lhhnC4v57?Yy*uca@CfHCo03u=0(aUhsJ6g
zX0+^xee>vFlNd3Q>}4!cQJ5^vSx_agnJqEcP>=UF3EN4M%49(ew&Oa6YdO!N`hmxZ
zKQD%vfSF%d`cg$C>^T_XE#w+BHdB;F1ByieVdTa~s@ur`DYfK3gtPOoY=
zZ>H#F6o>JO4LK2Pp0nZj+U!xOVqI{gqBeW@X+FR9CjGPHg1dVzaDVm%?JdQf^WxS<
zp=ZTOR)Ki8?oV4k(2&CWtqNEjg^NyXoSA{5$9ZSyWnM|SAVU#^Hg?Be5+dr2+eLex
z7tsHG2j(@S2Bc$eWcjC7z@5{%>E-pmpRZ{**uOCJZ^)9UQK>FQq@Gui64G{ytP48w
zydwkgp{${Ycf8EY7&&T`+EGihkOJ*g1LU`ayX~Z(pA8#vmI<|)a>JW^ofLcpZTg&B
z6iz?8sxJFV6gN+Ux+&RqfHm%)>p%GWZS%$~(33`LeO4S}4&tGTHXGklk{3+lVH3;a
z5-=d~V{f*Ww;nhb?@7a#m*wqVp$>L$gs|fbXu7kpDLfci5Dy4l+R|*bqfO}5lp_cwOCP=yoSfY0
zXpbD%s2Y^u_9x3ccp+u(?b?iU<7d;6us^3Oa{1r6{MT;Qf1|U{a^C*%mBxlwI$zZn
zZyC(rus5;BzcV`jVprC@lMyN4CSXNqs3k5XPEl&pQi^DChR^#|p%p?0J$%_)TV_jS
zN0^Oc17-u!d^0SuxID@k%pfxbS(Z*Mg|sVM-h6`gnYGO217UD=?I$RFbQy{AA{(0;
z8&1=a?|oO1qRwa8ZA`r;RQ;T|Z0JOpqbcm!3J!4v(F#d^$8YQ^r!t62tef?L28-t*sI{ljGn4cUp4}An@T82jI1DUmDbfzk
z>atsO*m{!imgvXX^A}mbO((+rB*N<>IuF@UCFXJoXFNaboo@YS)+7H+-NEEF-v$4=
z>-Q!j(h#w+b~Lhf)KPM?HFD5;%XyW=49bBpp#|QMw{&J}umEiJ!t`|4j0#BCL8q?U
zSTEyJeNV4bsdPbve8Q3>xsUEzUmly`#Az%-$wa4Cri*p1RDtC_eVn4k{Nj?3GwL@Ps``tj0zgVa|@h1VLRrZ_PKDJ+gS642tf@2zMJjjp`SHn;1|
zVd!_3NLr^ZTuvIyH?sBpqP7oVb8d+(qrdTl6
zhm?pMULf_wP?})P{+2$g=3ajtB)axH3nF%$+P2K?fUTt{*i|ZVio~HzFOm?YB~TRG
zbn5kSKJJ(1tH>FM=Vd_*J|Q{B0`DE0RtF%pZLrh9-n+b;i;?oqDypjin4^)^9XjC!
zkHWo`tCq-`R@$bE_6q~{M+kXoU=XxlnfBkZ#Xm>pb#Q=yfe2rZq7?sioPIa(-&IO)
z5$AV#Q$UuG{vCh*>GaPC^k(zh>G)qZzuuVt%h}s2`MbP1dn^B;%>1XnKed@};NSjU
z`}nunzm%GPQuL?v@NetD*GKvk17gMYGgNA@4_>CYSeGmyRc`OOj``G1)GkFfS$cXb
z-~XiT|GMcnrQh%JHo|VdNBAGQzkfd4e=7gpY=4hh&i}CeTL&mF4gTw8MSd02LcZdb
IDBtYC4(!0c;s%A!3-26OVp~
zZH(;ei97XV;zvM^7Lk(OzHh3a8Y^~toM?;0R{MKBSR&MI^
zK+dPtDIlK+wXtqHOfX)>GCQ#~YKn*tW~Cm-8D7hIf4AF&V$#^6Zxv;>DRUlNSdBmP
zPS`vH<6EjYILtisnHSxg*f6PU*RF0$rLd96Fu~ZA26n!id13Gk-V%P~y_7V$r}3>$
zCuT-{^5UFz$IURl=FEgdEGFnu&|kXU@kZvY)mJ>FQD`-q0d?ORAEkU(*r|JfqDM<|B!Z3-IEF)jL@9`@_YsP8ZG*~Dtn;7-w1
zSTYSLU}~bDm-?%;>4aI%UJ0w1;S6mMl@A_RMW(-FOnC!p+3D5YsZb7do?+|ItE*(&
z51ncF#9#x*qNqKv0Bydl15A!?hp+S1A0ub0W
zA+SIFJM8*aHii$lhsX3i;Qr=Z>_&LiU5`r1Syh|{dOJiy3=^xede;>1sel3A$PJ&&
zNM4~1c*OP~rGdq!nX{Yw3Soz)6giF(?oCELcKi(1rUfYMKz5Z^!Dn1u$Xgv?BB%X+
z?VD^FeUqw1>lLylrdY3>C34OAylfc3g0BGs(qpWS(%P8I!x|oQ+wOb^f&f%DG1>VgR`H|h9k9f?v2eLmO4V^G5)Z+O%(TywhnqTT7s=i}eaT0UqN?knTVCZfnRz^{Lz|9mU$T42jd?SNR4_`MjgV*$UM
zNzdV$y0Wn5wH^C}7hSIVb>J$MA&aY5b-_Cu>iBN4wzs&W##^_EQMj&wfMO-qy~_;sNj6Xf4V1SC|0@
z@b|(!?#Yo!mIM}W1%#Sp0sG1lNtXVU)Z*L(%bpziGDmHAAl{UOzzseswU&@!9)JY`
zg@P9>94|xSHbR3Wcr@=Qp_QdJUW2Y{mmv;qqgpp3>kcC1y1r
zd}9Vv2ecferB~k(1^E^oAa>GzF#_lM!(~3InpUN_*+M}aXc`CKVNFvzRI$wVTiZ0M
z9U}?2fGUI&eCk4gN^hQ?iW4O8i`E~mywbif2}Y+Do9=MFecZH#N!c1(3K+NQE7d31
z{g9#^Hm;_hLnG~!AHMP)U#
zO4qJ?KCu}vMwQQI>J}=cp=P5>Unq6(hqWYf;TqeYE3eK+Pw?umy;xH7;#H@l>+hX=
zaeH;?WF5}SAeI=D5^oniPyN<0c`#iWt@!$st;`ov0AX5-|Q6`)09Ip#fdjXpbzjhVxg%F+~Ie3S|zO>$`;uz5crc&SC
z!qW~ZE;h^6-)OgEc+_Pzg;-y9$
zNg$FIe?rSQg1>5p*=vXa6qdR;z)G~^JVrjw+p_1y9{?^bzN_5&jG0L{W298w2`dcV
zYa$)lzi@awO=)we9Z|$Zy!6WD8yxNZBU6e}QX8x(#f~!4T}=Lxc@n?w*yn+=flo0%
z!pF%9`3Z48fk91Yh*=J=F^Oo3aI~b+Q>omU+t2`3
zjyd(DPNl_pFNA?{HT;kE)+Nd!>Z~1LV9WAT@dX=S<4dCJ)+WiZI&B=WP*l?5?4zI@
z#%dwBgU{6gzyslYLm(e1J#$bAb0=90Kcnv>!jozd8!|wU3(xio(`X?Bp)Sbz5Fx*%
zH~_XdvLPC5H)QTsCuV)walaK;e99lX<
zg9*fuQ`Dy;6~~HDX=Ya;=M(fF*)=N@0}8M3XVp#Pg$(hu7z`(E+qRDBJ<>jyUw0}K
zkUsC!9%jG(Xwg+aJkXaZq7Nw{fkD9|RsVoR6bWaj6CPTVuB>
zyJjiFlU%iX={pki44o(sY54LFY_-e3aX7&%bX4mBrC5>xGKkIGRY%{$6^|uu`6F>A
z74`Am@MZuqkXN&-45d<jcpa93gr3?;Snwd`#JD8ccKG6D-Ohf)u
zsDdAuT)*cpL6ICEa-xgd*T+l3^i{e(sKQ6(sbVJ#5%gS;(nQg?5k(v8nQRytv8yX{
zCclud^vG;)&^c~Xr^GgNdayU8bP5&%Puc|Q>jA?AB)cHBSy+C34_5Lgae=r%WXc)s
z@tdaJiE-q4DIBIxdKS7`3pXS7TRum2{fC&~m&WQllNu@xHdhF(W6WifJNB-hQqrJx
z77(RN>E0xr-iemR4%2zZfqKmnc?qJVL-^e(^40XeRU4LxL|*igT9U-wMhtJE;xl
zWl#WbBWoy9@Q=Eq9mX3ZBPO;IQC
z(u%X!ufS1HQC!N?Jny$J6;8G2CW*2I
z(mcK&AK|Pysde06wNtg++&FWgGnnV2-=(wqNeh$^|`UFygQDQ-n=sKH=;?&z`
z!KcNK`uH+u<038!V{cz>d&)lY%14r4ZjDHgRsy%o)uR}b%Ql8-e|s~IkXeReG6iW
zt}^epfWE>`@qiTu(b!)TCckBnf5}as?flGETV(&xl%qpVK7e3|lTJ?{O5u@Q*jWRO
z?}0Uk@CnZabQkTMdjdw+Tt1Eu4=X@K9WE#>5ou5I<(SNExXF#hvnXFo{abs33iJDVBepUZIkYdg=%AU?0u
zBJbOB3Z>Q&OyA-zr;&Kjs7-r7KUi1;Ond@gS1J&V$b
z2h1e=j{Y7rk);j1DEIqWLL8b^S^t=*D|hH{-$DX(dH?~+D7qpb-TV&4rr>@yrJ7Yr
zy}j@q!4MP@@^v&aJjt~zQ0gT0bqR1^3rH@Blo&)4MEi{PEC?TieT=Q5@Z8~vKl*lF
zQjTHkqD*J{b(+!8%OPnQriGLjF$W-a#JjD5E=rb{YZJPoLk4GqZ|J6#_FSrYXI(=>
zHiO?JD-Bk`e8j5tQBFE5r*7k_Q`e{jvTB?&X$PuB~5N
z6$pDkeZm6QAxNXPA!J>}Snx(ML^%N9!ea?)z&YY-qj{yVwd%EM?*aMA-}!XS>8I6h
z+-3AP`V;#GdcK6RI(*RWns6}BEd*CnDPRUrP78H%cDjwzcsMO*UoUPF)GwzqQP5k!
z&Rn7V9#5nxe3ia}#1q7DP*4w{H01Be)9mEK-w)Z
z@4RdiLtko(LCOur-3wux*c+Sc%cj;`ittp1UpSibH^J#%e7j8@xK6jkyL&!<GN@
zP+`aE1D^Mu8!7jWtDhpL!w!Y|Vrw$HzU0#~$qk+OMN94MSRd^rPrlO*f?-nPD>OpY
z3JpzXB`u?lB>VrCtzn|Pte|>!;4Kh66ZF2^w5eG9d2WwhU2Y@_orpAn=)~Ik-Y;0fQ
z=scc2acdf?QmF;D7Pw$uLgU_rYlpQn0;`4V-k8EmVsjI=Jhqf+%Pwu@-$1+1`*H`)
zYI>^JJ5@URCY=Kp^>c!qlWPL4%9u9-2hcY>7^fW0<@Lq(9jsrFjJ
zKuyuN`(}dbA<$|>kV+bvx%QOjdHL&p_}T0)xgqno*UxtEIHC!L
z5H8DN8K`_Ukv_{HGAtQ={+cIm29_e#?MpZccdyMn--u>Bi5*Tt+WwN4JIRSE&75-A
zkwtdkyva%UkU9vyWbhcw+BHnTaVAQYHAtFqMQmx4uY%jotZ8@-gFE`nz01M9otVm=
zR>?|;*5r|A*F1CWMza~cJk=*`ZXDVOMr>e0j_tvSco~QGl`(#M&r56~Be_v2Ko1IA
zpHp}MliSCFY7uuieUQG8E-?)>UZ(?++Yy4zBy_j*w0U14#L7L7FS8!xtRcEx+&kLK*YR
z5=b0uq6h_r_8*S0b2c~pK@M}8_mQ(=SoZ|M_h1`%fs`rfUCxCwc8$jAPn=tiQ^(Ta
zU=*F78Bewa`>w>z35)q_tJNz+%X+tMkw@^vAH!-b3ToPGy5390?%#E^0IZD62a^p_
z>MbyM>~cG;Z|1L?&iPracv;TQ)>g`jzg66NubjRps#R0O`aF3&BXdxZ*=*nct)Q@%
zxWdJv@6^RXn6DCXdo8ux!_XByrT%Q3Z1ypx>@XhdST^yd6&AAZMD*MzgkZAOxfB!U~0j{d9E3Y6ER{3j!H41
z$WB9}oZyUifg1q5UHLwewS6gWyKM9rWP1bpbo$2{vZv*0W&M7C|*`UKr+zlOCo!oi?LqphM%(Y3J9y3l-=or%-b
z2zTb?%9xis8&cM+tW5
z<=w_m?hi9ElZ>M8$nqN+IIJdQ7Vxa
z3_7lwdsZ?$grwE!<4dh73`XTCfY(R{_r|C(7h_?3d&A~UKKVKg-8Xmb2<{8>2S=46
z*DwC;$6SO-Ckv#)++?2Xy-luVGl^Bfg8P)^8y6_z}o9$x%rlz>LSm_0o56{uATrKXV>0fG8t^Gcl
ze$KCDe5z5N(zCd7yks}I|8{(ex!=g%zV^&_Fe+m1Eg52V&^5N$*z=t%jkXip+S?@%zkbNI0x@{rm||J*3aJG?LlcO
zdd4EoLZCz6!luG3!%~$lo)mp6HKM6y(~rUv{-N{d^@r12ext`4EOe#@>d6lhVY|jL
z>sV;eI;T{ltYp`TInSp(dss*D)a(8E4{zA%uxOT!6Y|>kXnE?Y_hwR|EXmzB4ujkf
zw9~&Sy9h1XFJNSOyKHrRc0c*t-!~nFI8=CMNv^kUd@ud{$@sv
zfULWp3&rx9>s)>PtK0k5w;{yxQ=euBDriqb$lTL(Kq=ms6(cS$3Nfm%2cq}4S0l&v
z8XA&;i`L?%dfJ5f(mAJ0$n0)fCWAGMk(OVMCN%!YaZG24?!}Z2r?qFemuc;*y
zFrZ%DVKh|;A|J^`N6L{RiBa{7blW2?jT9qIjT9r!jTBpUb~kqwp1MJIWG6X@el7l%
zTg7N*uz?ig-9~TDsl7~W+$@u{YErHw7vmH|F8v#mgYWLx4C!l@s^ppLwdRu`iI5dX
zZtEqsZtb|0$20gavku*z&{g+-_-%gnPTe7N*D%)4{7@SceZ`}N8{+g%ulIwIW(qCO
z&=}Sh9JVUFJX@Bz13_is@bf32R!exhSh=UI8p9L@2Xc!w-DM%zVpHa{9oEJeG;=sr
z*s{7)Sh=JJLb*Kg6Ql(Qb2l~EvT_RKIjQ3X&3wCrQ_bRz-AC*CWsTM(9k0^sjDqCw
ze1+%dgmz6;;?eapc{E-J`s*Rb8k=D&d_+`d>3Ee}X%r-$7-pyom?w)1Wsc
zk0uMs@#)EWWWm5FC*|{KSQ^b5o{Vlx7u+O+ue2%ZoQ{0khT1J@M7F)@?S+rYdb=x_
ziOlHL%PQ|h{sD97u~SY)Uk`)4xKh*!GmC6u@$O;FHsS910Gg=c78Jv~{oPml4ENY*
zj>HA^(DQCSV@Qh?4p8$cJ|g43HE}6fBJjR7@oa)fLAD_n#+vonJqjq3OcJ_bQM7R%
z6JEOy&`V_MY#>t%I9bw0w)Wis3BUH;Oc46TVD{adCy-y8c`>(qLNw-uOxa(p_JnRJ
zc(E%UH$e`;&j%sX%kLVluNWZ{dat~P*^^!;>IX@`l^d0A`SnlvUI}y4q`c~$VWMk=zxO$%g_+yM%F%MF)@5Tk
zO|=Sgm`l^Qkog5k!6Yn(2FYqVg+>L}Pw#4<%=$DvI)D07;H>nH?Hae1zQTeC<>*8I
zp|J3kz}Mb5mFLF(XV^mm8FSpr+Nt#%a@}M{X!a+Y!+F6QRx`moKHR43T{Ak
zFZv*F;=SB|qk@buBo*+(l4rZ|c2Tdp=L*4h|IUSen
zEnyzt_b<-#SDxHq_e6uSWAw|hJ_>T*9iA*_3y5IYb>akzwR_$z>O*lfUkcWUZs<0c
ztMxuHl7XhI)b=U4(G43Z@qcX{SRRZ&oMVdOzYIhf6ha2(IHUM4j-aP?KRKgYQrt}c
zMlcsKof&-M<&3IUE4X@6Jr&aZ=%Zp(aLVoF^fX9SWYtgRd{G7l&(qEJCjUB{rv-1W
zneFcIyQ=ake3PY^UWA;71W=~$kbGx^o$qdA>tfxL%+@XfE?A6FmCG#A+n}Ay2S$5laPzt>`S28YEndt7Y
zg#0?CsDJU(L^H8O+9io^)j*MfHlE$IKw8c4ZnddT)N-*8f(8t`3cv#KFo_a`Hm?Rf
zG1s@^io{eU^HD07BvsGl2PkbIHn_~hewalV2_F_qd~krU>^lyz8;mSV7lvHWhMbck
z4OteMO)cG;xt?PEi|7VJYTF-6`bw{`eyj$CJA=4d8e>Q0!CQ;y@o6uD&Cb{CC=xB6
zs6{CK28KCM1+?|;5T+r~$tKJyp42^mUd{FekSBtZ-H-p0`U&pM7Pr$f^{G_A*Myy%)1N
zrZ0B}1NB#$0=?_1O_5SlhUz>rc
zKzWH(a|x2!2|ll=PGB%SQvXR;T{(cnnm7l~3eE}vblxvEJ2%J~#3(g}Se`*6fxanS
z0Xo2RV=Z@lM;!F2&gBsymXugUv6WS=6{pESLicI3h{I7%D^r&-bq^;#8x~2{U@Ci#
zoGq|0Rxa6h{{daOyj;XF<1{@htQ?R#)UXs2)%^j%OfAO+#@CIYG)57zWz%lhAkqBC
zYT$GRX0qs
zl*=*V)$wlE_#e?$!I8SHF(k@Hga27D&=9t=v@^7{d#~sWFtpWrh_p-N`eb03Fayph
zo7;0VSQG%dVY)g?hK1zIuw!SftS7)UpTn~>Y8@CM@33TX?!C*F`x_9dD6Lrtg~-_a
zc**-yRcINP+_@4XqS%DvG^~$)3T;k^Y#Ojt!-rQNUa29r`Vb*YP9
zfVl|VX&Py&*sfF;y3k8=s2H~Kw6pyJ{7}$o^aR4)oFF!zkc?fS*M?P#EtJ|i++knW
zW&Zj1fr|DT>eF82y@ACI`qwk=MO*Wy&C#_jbWPvE-}TvV5M(8xVK9GW#eO!J{JArb
z2N(((iWoBQL#BV&r=JG?xq|$_I6ucjuZjxcFaG)6>7NYrp!3tI>t8xQ-k5)L_V7yn
z91qSO#@~8qe)soh7tMq4Pk&T@SNl&-&7Tnc*$nbaI}P$k|K;?*ts#G^|JmK}OC1%G
zbNZ$JSFgjr-Sk5d|L1tPX(LF26GAutrtmvT*+hTir#~0^Cy_n)`H52R-_`zy*8a5f
zXZrP*9Zg7v?Vpmde_Hr6IrGbc7o17(d6u4hR0+;oo{PeqZ^YT^SF$KX
+课堂随机点名系统技术实现方案
+基于Python Tkinter的桌面应用开发方案
+
+
+| 文档版本 | 编写日期 |
+|---------|----------|
+| v1.0 | 2025年11月22日 |
+
+## 1. 技术栈选择说明
+
+
+
💡 技术选型决策依据
+
基于教育场景的特殊需求,选择Python + Tkinter技术栈具备明显的优势对比
+
+
+### 1.1 Python + Tkinter技术优势分析
+
+
+
+
🎯 开发效率优势
+
+- Python语法简洁,开发周期缩短40%
+- 丰富的第三方库支持数据操作
+- 快速原型验证和迭代开发
+
+
+
+
💻 部署便捷性
+
+- 跨平台兼容(Windows/macOS/Linux)
+- 无需复杂环境配置
+- 单文件打包分发
+
+
+
+
📊 教育场景适配
+
+- 教育资源丰富,易于教学维护
+- 教师友好型界面设计
+- 离线使用能力保障
+
+
+
+
+### 1.2 技术栈对比分析
+
+
+
+| 技术方案 | 开发难度 | 部署复杂度 | 性能表现 | 扩展性 | 维护成本 |
+|---------|---------|-----------|---------|--------|---------|
+| Python + Tkinter | 低 | 低 | 中等 | 高 | 低 |
+| Web方案(HTML+JS) | 中等 | 高 | 高 | 高 | 中等 |
+| C# WinForms | 中等 | 中等 | 高 | 中等 | 中等 |
+| Java Swing | 高 | 高 | 高 | 高 | 高 |
+
+
+
+### 1.3 核心依赖库选择
+
+```python
+# 核心依赖库配置
+required_libraries = {
+ "tkinter": "内置GUI框架",
+ "sqlite3": "内置轻量级数据库",
+ "pandas": "Excel文件处理和数据操作",
+ "openpyxl": "Excel文件读写支持",
+ "datetime": "日期时间处理",
+ "random": "随机算法实现",
+ "json": "配置文件处理"
+}
+```
+
+
+⚠️ 注意事项: 优先使用Python内置库,减少外部依赖,确保部署稳定性
+
+
+## 2. 核心模块功能实现方案
+
+### 2.1 系统架构设计
+
+```mermaid
+graph TB
+ A[用户界面层] --> B[业务逻辑层]
+ B --> C[数据访问层]
+
+ A1[主窗口Manager] --> A2[界面组件]
+ A2 --> A3[事件处理]
+
+ B1[点名引擎] --> B2[学生管理]
+ B2 --> B3[历史记录]
+ B3 --> B4[统计计算]
+
+ C1[SQLite数据库] --> C2[Excel文件]
+ C2 --> C3[配置文件]
+```
+
+### 2.2 点名引擎模块实现
+
+
+
🎲 随机算法核心实现
+
+
+```python
+class RandomSelector:
+ def __init__(self, student_list):
+ self.students = student_list
+ self.selected_history = []
+
+ def weighted_random_selection(self):
+ """加权随机选择算法,优先选择未点到学生"""
+ if not self.students:
+ return None
+
+ # 计算权重:未点到学生权重高,已点到学生权重低
+ weights = []
+ for student in self.students:
+ point_count = self.get_recent_point_count(student.id)
+ weight = max(1, 10 - point_count) # 最近点到次数越少,权重越高
+ weights.append(weight)
+
+ total_weight = sum(weights)
+ if total_weight == 0:
+ return random.choice(self.students)
+
+ # 执行加权随机选择
+ rand_val = random.uniform(0, total_weight)
+ cumulative = 0
+ for i, weight in enumerate(weights):
+ cumulative += weight
+ if rand_val <= cumulative:
+ return self.students[i]
+```
+
+### 2.3 动画效果实现方案
+
+```python
+class AnimationManager:
+ def __init__(self, canvas_widget):
+ self.canvas = canvas_widget
+ self.animation_id = None
+ self.is_animating = False
+
+ def start_roll_animation(self, duration=3000):
+ """开始滚动动画效果"""
+ self.is_animating = True
+ self.animation_start_time = time.time()
+ self.animate_roll()
+
+ def animate_roll(self):
+ if not self.is_animating:
+ return
+
+ elapsed = time.time() - self.animation_start_time
+ progress = min(elapsed / 3.0, 1.0) # 3秒动画周期
+
+ # 计算当前显示的学生索引(非线性缓动效果)
+ current_index = self.calculate_current_index(progress)
+ self.display_student(current_index)
+
+ if progress < 1.0:
+ # 继续动画,使用缓动函数调整速度
+ interval = self.calculate_interval(progress)
+ self.canvas.after(int(interval * 1000), self.animate_roll)
+ else:
+ self.finalize_selection()
+```
+
+### 2.4 文件导入模块实现
+
+```python
+class ExcelImporter:
+ def __init__(self):
+ self.supported_formats = ['.xlsx', '.xls', '.csv']
+
+ def import_students(self, file_path):
+ """导入Excel文件并解析学生数据"""
+ try:
+ if file_path.endswith('.csv'):
+ df = pd.read_csv(file_path)
+ else:
+ df = pd.read_excel(file_path, engine='openpyxl')
+
+ students = []
+ required_columns = ['学号', '姓名', '班级']
+
+ # 验证文件格式
+ if not all(col in df.columns for col in required_columns):
+ raise ValueError("文件格式错误:缺少必要列")
+
+ for _, row in df.iterrows():
+ student = Student(
+ id=str(row['学号']),
+ name=str(row['姓名']),
+ class_name=str(row['班级'])
+ )
+ students.append(student)
+
+ return students
+
+ except Exception as e:
+ raise Exception(f"文件导入失败:{str(e)}")
+```
+
+## 3. 数据库设计
+
+### 3.1 数据库架构设计
+
+```mermaid
+erDiagram
+ STUDENT ||--o{ POINT_HISTORY : has
+ STUDENT {
+ string student_id PK "学号"
+ string name "姓名"
+ string class_name "班级"
+ datetime created_at "创建时间"
+ datetime updated_at "更新时间"
+ }
+ POINT_HISTORY {
+ int history_id PK "记录ID"
+ string student_id FK "学号"
+ datetime point_time "点名时间"
+ string point_type "点名类型"
+ string note "备注"
+ }
+ SYSTEM_CONFIG {
+ string config_key PK "配置键"
+ string config_value "配置值"
+ datetime updated_at "更新时间"
+ }
+```
+
+### 3.2 数据表详细设计
+
+
+
+| 表名 | 字段名 | 数据类型 | 约束 | 说明 |
+|------|--------|---------|------|------|
+| **students** | student_id | VARCHAR(20) | PRIMARY KEY | 学生学号 |
+| | name | VARCHAR(50) | NOT NULL | 学生姓名 |
+| | class_name | VARCHAR(50) | NOT NULL | 班级名称 |
+| | created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
+| | updated_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | 更新时间 |
+| **point_history** | history_id | INTEGER | PRIMARY KEY AUTOINCREMENT | 记录ID |
+| | student_id | VARCHAR(20) | FOREIGN KEY | 学生学号 |
+| | point_time | DATETIME | NOT NULL | 点名时间 |
+| | point_type | VARCHAR(20) | DEFAULT 'normal' | 点名类型 |
+| | note | TEXT | | 备注信息 |
+| **system_config** | config_key | VARCHAR(50) | PRIMARY KEY | 配置键 |
+| | config_value | TEXT | | 配置值 |
+| | updated_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | 更新时间 |
+
+
+
+### 3.3 数据库操作封装
+
+```python
+class DatabaseManager:
+ def __init__(self, db_path="classroom_points.db"):
+ self.db_path = db_path
+ self.init_database()
+
+ def init_database(self):
+ """初始化数据库表结构"""
+ conn = sqlite3.connect(self.db_path)
+ cursor = conn.cursor()
+
+ # 创建学生表
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS students (
+ student_id VARCHAR(20) PRIMARY KEY,
+ name VARCHAR(50) NOT NULL,
+ class_name VARCHAR(50) NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ ''')
+
+ # 创建点名历史表
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS point_history (
+ history_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ student_id VARCHAR(20),
+ point_time DATETIME NOT NULL,
+ point_type VARCHAR(20) DEFAULT 'normal',
+ note TEXT,
+ FOREIGN KEY (student_id) REFERENCES students (student_id)
+ )
+ ''')
+
+ conn.commit()
+ conn.close()
+```
+
+## 4. 界面开发指南
+
+### 4.1 主界面布局实现
+
+```python
+class MainApplication:
+ def __init__(self):
+ self.root = tk.Tk()
+ self.root.title("课堂点名系统")
+ self.root.geometry("1200x800")
+ self.root.configure(bg="#ECF0F1")
+
+ self.setup_layout()
+ self.create_widgets()
+
+ def setup_layout(self):
+ """设置主界面布局结构"""
+ # 顶部功能区
+ self.top_frame = tk.Frame(self.root, height=80, bg="#3498DB")
+ self.top_frame.pack(fill=tk.X, padx=10, pady=5)
+
+ # 主内容区
+ self.main_frame = tk.Frame(self.root, bg="white")
+ self.main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
+
+ # 底部状态栏
+ self.status_frame = tk.Frame(self.root, height=40, bg="#2C3E50")
+ self.status_frame.pack(fill=tk.X, side=tk.BOTTOM)
+```
+
+### 4.2 组件样式定制
+
+```python
+def setup_styles(self):
+ """配置Tkinter样式"""
+ style = ttk.Style()
+
+ # 配置主按钮样式
+ style.configure('Primary.TButton',
+ background='#3498DB',
+ foreground='white',
+ font=('微软雅黑', 11, 'bold'),
+ padding=(20, 10))
+
+ # 配置表格样式
+ style.configure('Treeview',
+ font=('微软雅黑', 10),
+ rowheight=25)
+ style.configure('Treeview.Heading',
+ font=('微软雅黑', 11, 'bold'))
+```
+
+### 4.3 响应式布局适配
+
+
+✅ 最佳实践: 使用Grid布局管理器实现灵活的响应式设计
+
+
+```python
+def create_responsive_layout(self):
+ """创建响应式布局"""
+ # 左侧导航区
+ self.nav_frame = tk.Frame(self.main_frame, width=250, bg="#F8F9FA")
+ self.nav_frame.grid(row=0, column=0, rowspan=2, sticky="nswe", padx=(0, 10))
+
+ # 中央内容区
+ self.content_frame = tk.Frame(self.main_frame, bg="white")
+ self.content_frame.grid(row=0, column=1, sticky="nswe")
+
+ # 右侧统计区
+ self.stats_frame = tk.Frame(self.main_frame, width=200, bg="#F8F9FA")
+ self.stats_frame.grid(row=0, column=2, rowspan=2, sticky="nswe", padx=(10, 0))
+
+ # 配置权重使中央区域可伸缩
+ self.main_frame.grid_columnconfigure(1, weight=1)
+ self.main_frame.grid_rowconfigure(0, weight=1)
+```
+
+## 5. 部署和运行说明
+
+### 5.1 环境要求与依赖安装
+
+
+
+| 环境组件 | 版本要求 | 安装方法 | 验证命令 |
+|---------|---------|---------|---------|
+| Python | 3.8+ | 官网下载安装 | `python --version` |
+| pandas | 1.5.0+ | `pip install pandas` | `python -c "import pandas; print(pandas.__version__)"` |
+| openpyxl | 3.0.0+ | `pip install openpyxl` | `python -c "import openpyxl; print(openpyxl.__version__)"` |
+| 操作系统 | Win7+/macOS 10.12+/Ubuntu 16.04+ | - | - |
+
+
+
+### 5.2 项目目录结构
+
+```
+课堂点名系统/
+├── main.py # 主程序入口
+├── requirements.txt # 依赖包列表
+├── config/
+│ ├── settings.json # 配置文件
+│ └── database.db # SQLite数据库
+├── src/
+│ ├── gui/ # 界面模块
+│ │ ├── main_window.py # 主窗口
+│ │ ├── components.py # 组件类
+│ │ └── styles.py # 样式定义
+│ ├── core/ # 核心逻辑
+│ │ ├── random_selector.py # 随机算法
+│ │ ├── data_manager.py # 数据管理
+│ │ └── animation_engine.py # 动画引擎
+│ └── utils/ # 工具类
+│ ├── file_importer.py # 文件导入
+│ ├── database.py # 数据库操作
+│ └── helpers.py # 辅助函数
+├── assets/ # 资源文件
+│ ├── icons/ # 图标资源
+│ └── templates/ # Excel模板
+└── docs/ # 文档
+ └── 使用说明.md # 用户手册
+```
+
+### 5.3 打包发布方案
+
+```python
+# pyinstaller打包配置(spec文件)
+# classroom_points.spec
+
+block_cipher = None
+
+a = Analysis(
+ ['main.py'],
+ pathex=[],
+ binaries=[],
+ datas=[
+ ('assets/', 'assets/'),
+ ('config/', 'config/')
+ ],
+ hiddenimports=[],
+ hookspath=[],
+ hooksconfig={},
+ runtime_hooks=[],
+ excludes=[],
+ win_no_prefer_redirects=False,
+ win_private_assemblies=False,
+ cipher=block_cipher,
+ noarchive=False,
+)
+
+pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ a.binaries,
+ a.zipfiles,
+ a.datas,
+ [],
+ name='课堂点名系统',
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=True,
+ upx_exclude=[],
+ runtime_tmpdir=None,
+ console=False, # 设置为True可显示控制台窗口
+ icon='assets/icon.ico'
+)
+```
+
+### 5.4 部署操作指南
+
+
+📋 部署步骤: 按照以下步骤完成系统部署
+
+
+**步骤1:环境准备**
+```bash
+# 1. 安装Python 3.8或更高版本
+# 2. 下载项目代码
+git clone <项目仓库>
+cd 课堂点名系统
+
+# 3. 安装依赖
+pip install -r requirements.txt
+```
+
+**步骤2:首次运行配置**
+```bash
+# 1. 运行主程序
+python main.py
+
+# 2. 系统将自动创建配置文件和数据文件
+# 3. 根据需要调整系统设置
+```
+
+**步骤3:打包分发(可选)**
+```bash
+# 使用PyInstaller打包为可执行文件
+pip install pyinstaller
+pyinstaller classroom_points.spec
+
+# 生成的exe文件在dist目录下
+```
+
+### 5.5 故障排除指南
+
+
+
+
❌ 常见问题1:依赖包安装失败
+
解决方案:使用国内镜像源安装
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
+
+
+
❌ 常见问题2:Excel导入失败
+
解决方案:检查Excel文件格式,确保包含学号、姓名、班级三列
+
+
+
❌ 常见问题3:界面显示异常
+
解决方案:调整系统DPI设置或使用兼容模式运行
+
+
+
+## 6. 性能优化建议
+
+### 6.1 数据库性能优化
+
+```python
+def optimize_database_performance(self):
+ """数据库性能优化配置"""
+ conn = sqlite3.connect(self.db_path)
+ cursor = conn.cursor()
+
+ # 启用WAL模式提高并发性能
+ cursor.execute("PRAGMA journal_mode=WAL")
+
+ # 设置合适的缓存大小
+ cursor.execute("PRAGMA cache_size=-64000") # 64MB缓存
+
+ # 创建索引提升查询性能
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_student_class ON students(class_name)")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_history_time ON point_history(point_time)")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_history_student ON point_history(student_id)")
+
+ conn.commit()
+ conn.close()
+```
+
+### 6.2 内存管理优化
+
+
+
💡 内存优化策略
+
+- 使用分页加载大量学生数据
+- 及时释放不再使用的界面组件
+- 优化图片和资源文件加载
+- 定期清理临时数据
+
+
+
+## 7. 安全与稳定性保障
+
+### 7.1 数据安全措施
+
+- **数据备份机制**:自动定期备份数据库文件
+- **输入验证**:对所有用户输入进行严格验证
+- **异常处理**:完善的异常捕获和处理机制
+- **日志记录**:详细的操作日志记录系统
+
+### 7.2 系统稳定性策略
+
+```python
+def setup_error_handling(self):
+ """设置全局异常处理"""
+ def global_exception_handler(exc_type, exc_value, exc_traceback):
+ if issubclass(exc_type, KeyboardInterrupt):
+ sys.__excepthook__(exc_type, exc_value, exc_traceback)
+ return
+
+ logger.error("未捕获的异常:", exc_info=(exc_type, exc_value, exc_traceback))
+
+ # 显示用户友好的错误信息
+ messagebox.showerror(
+ "系统错误",
+ "发生意外错误,程序将继续运行。\n错误信息已记录到日志。"
+ )
+
+ sys.excepthook = global_exception_handler
+```
+
+---
+
+**文档完成时间:2025年11月22日**
diff --git a/class-call-system (1)/docs/prototype/项目原型.md b/class-call-system (1)/docs/prototype/项目原型.md
new file mode 100644
index 0000000..e69de29
diff --git a/class-call-system (1)/main.py b/class-call-system (1)/main.py
new file mode 100644
index 0000000..bac2456
--- /dev/null
+++ b/class-call-system (1)/main.py
@@ -0,0 +1,11 @@
+import ttkbootstrap as ttk
+from src.gui.main_window import MainWindow
+from src.utils.database import db_instance
+import sys
+import os
+
+if __name__ == "__main__":
+ root = ttk.Window(themename="flatly")
+ app = MainWindow(root)
+ root.mainloop()
+ db_instance.close()
\ No newline at end of file
diff --git a/class-call-system (1)/requirements.txt b/class-call-system (1)/requirements.txt
new file mode 100644
index 0000000..ca6c731
--- /dev/null
+++ b/class-call-system (1)/requirements.txt
@@ -0,0 +1,7 @@
+pandas>=2.0.0
+openpyxl>=3.1.0
+matplotlib>=3.7.0
+ttkbootstrap>=1.10.0
+Pillow>=10.0.0
+pywin32>=306
+pymysql>=1.1.0
\ No newline at end of file
diff --git a/class-call-system (1)/src/core/animation_engine.py b/class-call-system (1)/src/core/animation_engine.py
new file mode 100644
index 0000000..406ccb9
--- /dev/null
+++ b/class-call-system (1)/src/core/animation_engine.py
@@ -0,0 +1,67 @@
+import random
+import json
+import ttkbootstrap as ttk
+from src.utils.database import db_instance
+
+# 读取配置
+with open("config/settings.json", "r", encoding="utf-8") as f:
+ CONFIG = json.load(f)
+ANIM_CONFIG = CONFIG["animation"]
+
+class AnimationEngine:
+ """点名动画引擎(名字滚动、闪烁)"""
+ def __init__(self, label: ttk.Label):
+ self.label = label # 展示动画的标签
+ self.students = []
+ self.is_running = False
+ self.roll_count = 0
+
+ def refresh_students(self):
+ """刷新学生列表"""
+ self.students = db_instance.get_all_students()
+
+ def _roll_name(self, final_student: tuple = None):
+ """名字滚动动画"""
+ if not self.students:
+ self.label.config(text="暂无学生数据")
+ return
+
+ if self.roll_count >= ANIM_CONFIG["roll_times"] and final_student:
+ # 动画结束,显示最终结果
+ self.is_running = False
+ self.label.config(
+ text=f"🎉 点名结果 🎉\n学号:{final_student[0]}\n姓名:{final_student[1]}\n专业:{final_student[2]}",
+ font=("微软雅黑", 16, "bold")
+ )
+ # 闪烁效果
+ self._flash_label()
+ return
+
+ # 随机显示学生名字(滚动)
+ random_student = random.choice(self.students)
+ self.label.config(
+ text=f"正在随机点名...\n{random_student[1]}",
+ font=("微软雅黑", 14)
+ )
+ self.roll_count += 1
+ # 递归调用,实现滚动
+ self.label.after(ANIM_CONFIG["roll_speed"], self._roll_name, final_student)
+
+ def _flash_label(self):
+ """标签闪烁效果"""
+ current_color = self.label.cget("foreground")
+ new_color = "red" if current_color != "red" else "black"
+ self.label.config(foreground=new_color)
+ # 持续闪烁1秒
+ self.label.after(ANIM_CONFIG["flash_duration"], lambda: self.label.config(foreground="black"))
+
+ def start_roll(self, final_student: tuple):
+ """启动滚动动画"""
+ self.is_running = True
+ self.roll_count = 0
+ self.refresh_students()
+ self._roll_name(final_student)
+
+# 示例:创建动画引擎时传入ttk.Label对象
+# label = ttk.Label(text="测试")
+# anim_engine = AnimationEngine(label)
\ No newline at end of file
diff --git a/class-call-system (1)/src/core/data_manager.py b/class-call-system (1)/src/core/data_manager.py
new file mode 100644
index 0000000..b49df8f
--- /dev/null
+++ b/class-call-system (1)/src/core/data_manager.py
@@ -0,0 +1,94 @@
+from src.utils.database import db_instance
+from src.utils.file_importer import excel_importer
+from src.core.random_selector import random_selector, order_selector
+from src.core.animation_engine import AnimationEngine
+from src.utils.helpers import get_answer_score
+
+class DataManager:
+ """数据管理中间层,协调各模块"""
+ def __init__(self):
+ self.db = db_instance
+ self.excel = excel_importer
+ self.random_selector = random_selector
+ self.order_selector = order_selector
+ self.anim_engine = None
+ # 新增:检查数据库实例是否初始化成功
+ if self.db is None:
+ raise Exception("数据库连接初始化失败,请检查config/settings.json中的MySQL配置")
+
+ def init_animation(self, label):
+ """初始化动画引擎"""
+ self.anim_engine = AnimationEngine(label)
+
+ def import_excel(self, file_path):
+ """导入Excel学生名单(新增:操作前重连数据库)"""
+ if self.db:
+ self.db.reconnect() # 解决db4free空闲断开连接问题
+ return self.excel.import_students(file_path)
+
+ def export_excel(self, file_path=None):
+ """导出积分详单(新增:操作前重连数据库)"""
+ if self.db:
+ self.db.reconnect()
+ return self.excel.export_scores(file_path)
+
+ def random_call(self):
+ """随机点名(含动画,新增:数据库重连+空值检查)"""
+ if self.db is None:
+ return None, "数据库未连接,无法获取学生数据"
+ self.db.reconnect() # 重连数据库
+ self.random_selector.refresh_students()
+ student, msg = self.random_selector.select()
+ if student and self.anim_engine:
+ self.anim_engine.start_roll(student)
+ return student, msg
+
+ def order_call(self):
+ """顺序点名(无动画,直接显示,优化:固定顺序不重置+空数据处理)"""
+ if self.db is None:
+ return None, "数据库未连接,无法获取学生数据"
+
+ self.db.reconnect() # 重连数据库
+
+ # 仅在首次调用或学生列表为空时刷新数据(避免每次调用重置顺序)
+ if not hasattr(self.order_selector, 'students') or len(self.order_selector.students) == 0:
+ self.order_selector.refresh_students()
+
+ # 检查学生列表是否为空
+ if len(self.order_selector.students) == 0:
+ return None, "没有可用的学生数据,请先导入学生名单"
+
+ student, msg = self.order_selector.select()
+
+ # 当所有学生点完一轮后,自动重置顺序(可选逻辑,根据需求调整)
+ if "已完成一轮" in msg:
+ self.order_selector.refresh_students() # 重置为初始顺序
+ msg += ",已自动重置顺序"
+
+ return student, msg
+
+ def record_call(self, student_id, call_mode, is_arrived, answer_level):
+ """记录点名结果(新增:数据库重连+异常捕获)"""
+ if self.db is None:
+ return False
+ self.db.reconnect() # 重连数据库
+ try:
+ answer_score = get_answer_score(answer_level)
+ return self.db.add_call_record(student_id, call_mode, is_arrived, answer_score)
+ except Exception as e:
+ print(f"记录点名结果失败:{str(e)}")
+ return False
+
+ def get_all_students(self):
+ """获取所有学生(新增:数据库重连+空值检查)"""
+ if self.db is None:
+ return []
+ self.db.reconnect() # 重连数据库
+ return self.db.get_all_students()
+
+# 单例(新增:异常捕获,避免数据库初始化失败导致程序崩溃)
+try:
+ data_manager = DataManager()
+except Exception as e:
+ print(f"DataManager初始化失败:{e}")
+ data_manager = None # 初始化失败时设为None
\ No newline at end of file
diff --git a/class-call-system (1)/src/core/random_selector.py b/class-call-system (1)/src/core/random_selector.py
new file mode 100644
index 0000000..34dacb7
--- /dev/null
+++ b/class-call-system (1)/src/core/random_selector.py
@@ -0,0 +1,55 @@
+import random
+from src.utils.database import db_instance
+from src.utils.helpers import calculate_weight
+
+class RandomSelector:
+ """随机点名算法类"""
+ def __init__(self):
+ self.students = []
+ self.refresh_students()
+
+ def refresh_students(self):
+ """刷新学生列表"""
+ self.students = db_instance.get_all_students()
+
+ def select(self) -> tuple:
+ """随机选择学生(积分越高概率越低)"""
+ if not self.students:
+ return None, "暂无学生数据,请先导入名单"
+
+ # 提取学号和积分
+ student_ids = [s[0] for s in self.students]
+ scores = [s[3] for s in self.students]
+ # 计算权重
+ weights = calculate_weight(scores)
+ # 按权重随机选择
+ selected_id = random.choices(student_ids, weights=weights, k=1)[0]
+ # 获取选中学生详情
+ selected_student = [s for s in self.students if s[0] == selected_id][0]
+ return selected_student, "随机点名成功"
+
+class OrderSelector:
+ """顺序点名算法类"""
+ def __init__(self):
+ self.students = []
+ self.index = 0
+ self.refresh_students()
+
+ def refresh_students(self):
+ """刷新学生列表"""
+ self.students = db_instance.get_all_students()
+ self.index = 0 # 重置索引
+
+ def select(self) -> tuple:
+ """按学号顺序选择学生"""
+ if not self.students:
+ return None, "暂无学生数据,请先导入名单"
+
+ # 循环选择
+ selected_student = self.students[self.index]
+ self.index = (self.index + 1) % len(self.students)
+ return selected_student, "顺序点名成功"
+
+# 单例
+random_selector = RandomSelector()
+order_selector = OrderSelector()
\ No newline at end of file
diff --git a/class-call-system (1)/src/gui/components.py b/class-call-system (1)/src/gui/components.py
new file mode 100644
index 0000000..e81f57e
--- /dev/null
+++ b/class-call-system (1)/src/gui/components.py
@@ -0,0 +1,128 @@
+import ttkbootstrap as ttk
+import tkinter as tk
+from src.gui.styles import *
+# 新增:导入Matplotlib字体配置和刻度模块
+import matplotlib
+import matplotlib.ticker as ticker # 关键:用于设置刻度间隔
+matplotlib.rcParams["font.family"] = ["SimHei", "Microsoft YaHei", "DejaVu Sans"] # 优先中文字体
+matplotlib.rcParams["axes.unicode_minus"] = False # 解决负号显示为方块的问题
+
+class CustomButton(ttk.Button):
+ """自定义按钮组件"""
+ def __init__(self, parent, text, command=None, style="Primary.TButton", **kwargs):
+ super().__init__(
+ parent,
+ text=text,
+ command=command,
+ style=style,
+ width=BUTTON_WIDTH,** kwargs
+ )
+
+class ResultDisplay(ttk.Label):
+ """点名结果展示标签"""
+ def __init__(self, parent):
+ super().__init__(
+ parent,
+ text="等待点名...",
+ style="Result.TLabel",
+ anchor="center",
+ justify="center"
+ )
+
+class StudentTable(ttk.Treeview):
+ """学生名单表格"""
+ def __init__(self, parent):
+ super().__init__(parent, show="headings")
+ # 定义列
+ self["columns"] = ("学号", "姓名", "专业", "总积分", "随机点名次数")
+ # 设置列标题和宽度
+ for col in self["columns"]:
+ self.heading(col, text=col)
+ self.column(col, width=100, anchor="center")
+ # 滚动条
+ scroll_y = ttk.Scrollbar(parent, orient="vertical", command=self.yview)
+ self.configure(yscrollcommand=scroll_y.set)
+ scroll_y.pack(side="right", fill="y")
+ self.pack(fill="both", expand=True)
+
+ def refresh_data(self, data):
+ """刷新表格数据"""
+ # 清空原有数据
+ for item in self.get_children():
+ self.delete(item)
+ # 添加新数据
+ for row in data:
+ self.insert("", "end", values=row)
+
+class VisualizationFrame(ttk.Frame):
+ """可视化图表容器(简易版,结合matplotlib)"""
+ def __init__(self, parent):
+ super().__init__(parent)
+ self.canvas = None
+
+ def show_chart(self, students):
+ """显示积分和点名次数图表(优化折线图纵轴刻度+数据读取)"""
+ from matplotlib.figure import Figure
+ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
+ import json
+ import os # 新增:处理路径
+
+ # 修复配置文件路径(沿用之前的绝对路径逻辑,避免相对路径错误)
+ current_file = os.path.abspath(__file__)
+ utils_dir = os.path.dirname(os.path.dirname(current_file)) # 上级目录
+ project_root = os.path.dirname(utils_dir)
+ config_path = os.path.join(project_root, "config", "settings.json")
+
+ with open(config_path, "r", encoding="utf-8") as f:
+ vis_config = json.load(f)["visualization"]
+
+ # 创建图表
+ fig = Figure(figsize=vis_config["fig_size"], dpi=100)
+ fig.suptitle("学生积分与点名次数可视化", fontsize=12)
+
+ # 过滤有效数据(确保至少包含5个字段,避免索引错误)
+ valid_students = [s for s in students if len(s)>=5]
+ # 提取可视化数据(仅取TOP-N,适配配置)
+ top_n = vis_config["top_n"]
+ show_students = valid_students[:top_n] if len(valid_students)>=top_n else valid_students
+ names = [s[1] for s in show_students]
+ scores = [s[3] for s in show_students]
+ call_nums = [s[4] for s in show_students]
+
+ # 子图1:积分柱形图
+ ax1 = fig.add_subplot(121)
+ if names: # 有数据才绘制
+ ax1.bar(names, scores, color=COLOR_PRIMARY)
+ else:
+ ax1.text(0.5, 0.5, "暂无学生数据", ha="center", va="center", transform=ax1.transAxes)
+ ax1.set_title(f"TOP{vis_config['top_n']}积分排名")
+ ax1.set_xlabel("姓名")
+ ax1.set_ylabel("总积分")
+ ax1.tick_params(axis="x", rotation=45)
+
+ # 子图2:点名次数折线图(核心优化:纵轴从0开始+刻度为1)
+ ax2 = fig.add_subplot(122)
+ if names: # 有数据才绘制
+ ax2.plot(names, call_nums, marker="o", color=COLOR_SECONDARY, linestyle="-", linewidth=2, markersize=6)
+ # 强制纵轴从0开始
+ ax2.set_ylim(bottom=0)
+ # 设置纵轴刻度间隔为1
+ ax2.yaxis.set_major_locator(ticker.MultipleLocator(1))
+ # 可选:显示网格,方便查看刻度
+ ax2.grid(axis="y", linestyle="--", alpha=0.7)
+ else:
+ ax2.text(0.5, 0.5, "暂无点名数据", ha="center", va="center", transform=ax2.transAxes)
+ ax2.set_title(f"TOP{vis_config['top_n']}随机点名次数")
+ ax2.set_xlabel("姓名")
+ ax2.set_ylabel("点名次数")
+ ax2.tick_params(axis="x", rotation=45)
+
+ # 调整子图间距,避免标题/标签重叠
+ fig.tight_layout()
+
+ # 嵌入Tkinter
+ if self.canvas:
+ self.canvas.get_tk_widget().destroy()
+ self.canvas = FigureCanvasTkAgg(fig, master=self)
+ self.canvas.draw()
+ self.canvas.get_tk_widget().pack(fill="both", expand=True)
\ No newline at end of file
diff --git a/class-call-system (1)/src/gui/main_window.py b/class-call-system (1)/src/gui/main_window.py
new file mode 100644
index 0000000..f048db6
--- /dev/null
+++ b/class-call-system (1)/src/gui/main_window.py
@@ -0,0 +1,163 @@
+import ttkbootstrap as ttk
+from tkinter import filedialog, messagebox
+from src.gui.components import CustomButton, ResultDisplay, StudentTable, VisualizationFrame
+from src.core.data_manager import data_manager
+from src.gui.styles import *
+from src.utils.helpers import get_visual_top_n
+
+class MainWindow:
+ """主窗口类"""
+ def __init__(self, root):
+ self.root = root
+ self.root.title("课堂随机点名系统")
+ self.root.geometry("1000x700")
+ self.root.resizable(True, True)
+
+ # 初始化数据管理器的动画引擎
+ self.result_label = ResultDisplay(self.root)
+ data_manager.init_animation(self.result_label)
+
+ # 构建界面
+ self._create_layout()
+ # 刷新学生表格
+ self._refresh_table()
+
+ def _create_layout(self):
+ """构建界面布局"""
+ # 顶部标题栏
+ title_frame = ttk.Frame(self.root)
+ title_frame.pack(fill="x", pady=PADDING_MEDIUM)
+ title_label = ttk.Label(title_frame, text="课堂随机点名系统", font=FONT_TITLE, foreground=COLOR_PRIMARY)
+ title_label.pack()
+
+ # 功能按钮栏
+ btn_frame = ttk.Frame(self.root)
+ btn_frame.pack(fill="x", padx=PADDING_LARGE, pady=PADDING_SMALL)
+
+ # 导入Excel按钮
+ CustomButton(btn_frame, text="导入学生名单", command=self._import_excel, style="Primary.TButton").pack(side="left", padx=PADDING_SMALL)
+ # 随机点名按钮
+ CustomButton(btn_frame, text="随机点名", command=self._random_call, style="Success.TButton").pack(side="left", padx=PADDING_SMALL)
+ # 顺序点名按钮
+ CustomButton(btn_frame, text="顺序点名", command=self._order_call, style="Success.TButton").pack(side="left", padx=PADDING_SMALL)
+ # 导出积分按钮
+ CustomButton(btn_frame, text="导出积分详单", command=self._export_excel, style="Primary.TButton").pack(side="left", padx=PADDING_SMALL)
+ # 可视化按钮
+ CustomButton(btn_frame, text="积分可视化", command=self._show_visual, style="Primary.TButton").pack(side="left", padx=PADDING_SMALL)
+
+ # 点名结果展示区
+ result_frame = ttk.Frame(self.root, borderwidth=2, relief="groove")
+ result_frame.pack(fill="x", padx=PADDING_LARGE, pady=PADDING_MEDIUM)
+ self.result_label.pack(pady=PADDING_MEDIUM)
+
+ # 积分记录按钮区
+ record_frame = ttk.Frame(self.root)
+ record_frame.pack(fill="x", padx=PADDING_LARGE, pady=PADDING_SMALL)
+ # 到课按钮
+ CustomButton(record_frame, text="到课(+1.5分)", command=lambda: self._record_call(True, "及格"), style="Success.TButton").pack(side="left", padx=PADDING_SMALL)
+ # 未到课按钮
+ CustomButton(record_frame, text="未到课(-1.0分)", command=lambda: self._record_call(False, "错误"), style="Primary.TButton").pack(side="left", padx=PADDING_SMALL)
+ # 回答优秀按钮
+ CustomButton(record_frame, text="回答优秀(+4分)", command=lambda: self._record_call(True, "优秀"), style="Success.TButton").pack(side="left", padx=PADDING_SMALL)
+ # 重复问题正确
+ CustomButton(record_frame, text="重复正确(+1.5分)", command=lambda: self._record_call(True, "重复正确"), style="Success.TButton").pack(side="left", padx=PADDING_SMALL)
+ # 重复问题错误
+ CustomButton(record_frame, text="错误(+0分)", command=lambda: self._record_call(True, "重复错误"), style="Primary.TButton").pack(side="left", padx=PADDING_SMALL)
+
+ # 学生表格区
+ table_frame = ttk.Frame(self.root, borderwidth=2, relief="groove")
+ table_frame.pack(fill="both", expand=True, padx=PADDING_LARGE, pady=PADDING_MEDIUM)
+ table_label = ttk.Label(table_frame, text="学生名单", font=FONT_SUBTITLE, foreground=COLOR_TEXT)
+ table_label.pack(pady=PADDING_SMALL)
+ self.student_table = StudentTable(table_frame)
+
+ # 可视化窗口(隐藏,点击按钮后显示)
+ self.visual_window = None
+
+ def _import_excel(self):
+ """导入Excel学生名单"""
+ file_path = filedialog.askopenfilename(filetypes=[("Excel文件", "*.xlsx")])
+ if not file_path:
+ return
+ success, msg = data_manager.import_excel(file_path)
+ if success:
+ messagebox.showinfo("导入成功", msg)
+ self._refresh_table()
+ else:
+ messagebox.showerror("导入失败", msg)
+
+ def _export_excel(self):
+ """导出积分详单"""
+ file_path = filedialog.asksaveasfilename(defaultextension=".xlsx", filetypes=[("Excel文件", "*.xlsx")])
+ if not file_path:
+ return
+ success, msg = data_manager.export_excel(file_path)
+ if success:
+ messagebox.showinfo("导出成功", msg)
+ else:
+ messagebox.showerror("导出失败", msg)
+
+ def _random_call(self):
+ """随机点名"""
+ student, msg = data_manager.random_call()
+ if not student:
+ messagebox.showwarning("警告", msg)
+ # 动画由AnimationEngine自动处理,无需额外更新标签
+ self.current_student = student # 保存当前点名学生
+ # 记录本次点名模式,供记录积分时使用(避免从标签文本解析)
+ self.current_call_mode = "random"
+
+ def _order_call(self):
+ """顺序点名"""
+ student, msg = data_manager.order_call()
+ if not student:
+ messagebox.showwarning("警告", msg)
+ return
+ # 显示顺序点名结果
+ self.result_label.config(
+ text=f"📋 顺序点名结果 📋\n学号:{student[0]}\n姓名:{student[1]}\n专业:{student[2]}",
+ font=FONT_SUBTITLE
+ )
+ self.current_student = student # 保存当前点名学生
+ # 记录本次点名模式
+ self.current_call_mode = "order"
+
+ def _record_call(self, is_arrived, answer_level):
+ """记录点名结果(积分)"""
+ if not hasattr(self, "current_student") or not self.current_student:
+ messagebox.showwarning("警告", "请先进行点名")
+ return
+ student_id = self.current_student[0]
+ # 优先使用显式记录的点名模式(`current_call_mode`),避免依赖标签文本内容
+ call_mode = getattr(self, "current_call_mode", None)
+ if call_mode not in ("random", "order"):
+ # 回退到原有文本解析逻辑(兼容旧文本)
+ call_mode = "random" if "随机" in self.result_label.cget("text") else "order"
+ success = data_manager.record_call(student_id, call_mode, is_arrived, answer_level)
+ if success:
+ messagebox.showinfo("成功", "积分记录成功")
+ self._refresh_table()
+ else:
+ messagebox.showerror("失败", "积分记录失败")
+
+ def _show_visual(self):
+ """显示积分可视化"""
+ students = data_manager.get_all_students()
+ if not students:
+ messagebox.showwarning("警告", "暂无学生数据")
+ return
+ # 创建可视化窗口
+ self.visual_window = ttk.Toplevel(self.root)
+ self.visual_window.title("积分可视化")
+ self.visual_window.geometry("800x500")
+ # 可视化组件
+ visual_frame = VisualizationFrame(self.visual_window)
+ visual_frame.pack(fill="both", expand=True)
+ # 显示TOP N学生图表
+ top_students = get_visual_top_n(students)
+ visual_frame.show_chart(top_students)
+
+ def _refresh_table(self):
+ """刷新学生表格"""
+ students = data_manager.get_all_students()
+ self.student_table.refresh_data(students)
\ No newline at end of file
diff --git a/class-call-system (1)/src/gui/styles.py b/class-call-system (1)/src/gui/styles.py
new file mode 100644
index 0000000..fdce1b2
--- /dev/null
+++ b/class-call-system (1)/src/gui/styles.py
@@ -0,0 +1,48 @@
+import ttkbootstrap as ttk
+
+# 颜色配置
+COLOR_PRIMARY = "#2E86AB"
+COLOR_SECONDARY = "#A23B72"
+COLOR_SUCCESS = "#F18F01"
+COLOR_WARNING = "#C73E1D"
+COLOR_TEXT = "#333333"
+
+# 字体配置
+FONT_TITLE = ("微软雅黑", 18, "bold")
+FONT_SUBTITLE = ("微软雅黑", 14, "bold")
+FONT_NORMAL = ("微软雅黑", 12)
+FONT_SMALL = ("微软雅黑", 10)
+
+# 布局配置
+PADDING_SMALL = 5
+PADDING_MEDIUM = 10
+PADDING_LARGE = 20
+BUTTON_WIDTH = 15
+BUTTON_HEIGHT = 2
+
+# 组件样式
+STYLE_BUTTON_PRIMARY = ttk.Style()
+STYLE_BUTTON_PRIMARY.configure(
+ "Primary.TButton",
+ font=FONT_NORMAL,
+ foreground="white",
+ background=COLOR_PRIMARY,
+ padding=PADDING_SMALL
+)
+
+STYLE_BUTTON_SUCCESS = ttk.Style()
+STYLE_BUTTON_SUCCESS.configure(
+ "Success.TButton",
+ font=FONT_NORMAL,
+ foreground="white",
+ background=COLOR_SUCCESS,
+ padding=PADDING_SMALL
+)
+
+STYLE_LABEL_RESULT = ttk.Style()
+STYLE_LABEL_RESULT.configure(
+ "Result.TLabel",
+ font=FONT_SUBTITLE,
+ foreground=COLOR_TEXT,
+ padding=PADDING_MEDIUM
+)
\ No newline at end of file
diff --git a/class-call-system (1)/src/utils/database.py b/class-call-system (1)/src/utils/database.py
new file mode 100644
index 0000000..0dd3b58
--- /dev/null
+++ b/class-call-system (1)/src/utils/database.py
@@ -0,0 +1,246 @@
+import pymysql
+import json
+import os
+from datetime import datetime
+
+# 读取配置文件
+# 在 database.py 中添加项目根目录计算(与 file_importer.py 逻辑相同)
+current_file = os.path.abspath(__file__) # src/utils/database.py
+utils_dir = os.path.dirname(current_file)
+src_dir = os.path.dirname(utils_dir)
+project_root = os.path.dirname(src_dir)
+config_path = os.path.join(project_root, "config", "settings.json")
+
+# 读取配置文件
+with open(config_path, "r", encoding="utf-8") as f:
+ CONFIG = json.load(f)
+
+# 数据库配置
+DB_TYPE = CONFIG["database"]["type"]
+MYSQL_CONFIG = CONFIG["database"]["mysql"]
+TABLE_STUDENT = CONFIG["database"]["table_student"]
+TABLE_RECORD = CONFIG["database"]["table_record"]
+
+class Database:
+ """MySQL 数据库操作类(适配 db4free 免费 MySQL)"""
+ def __init__(self):
+ self.conn = None
+ self.cursor = None
+ self.connect() # 初始化连接
+ self.create_tables() # 初始化表结构
+
+ def connect(self):
+ """连接 MySQL 数据库(处理 db4free 连接特性)"""
+ try:
+ if DB_TYPE == "mysql":
+ self.conn = pymysql.connect(
+ host=MYSQL_CONFIG["host"],
+ port=MYSQL_CONFIG["port"],
+ user=MYSQL_CONFIG["user"],
+ password=MYSQL_CONFIG["password"],
+ database=MYSQL_CONFIG["dbname"],
+ charset=MYSQL_CONFIG["charset"],
+ connect_timeout=30 # db4free 外网连接超时设为30秒
+ )
+ self.cursor = self.conn.cursor()
+ print(f"成功连接 db4free MySQL 数据库:{MYSQL_CONFIG['dbname']}")
+ # 保留 SQLite 兼容(可选)
+ elif DB_TYPE == "sqlite":
+ import sqlite3
+ db_path = "config/database.db"
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
+ self.conn = sqlite3.connect(db_path, check_same_thread=False)
+ self.cursor = self.conn.cursor()
+ except pymysql.MySQLError as e:
+ raise Exception(f"MySQL 连接失败:{e}") # 抛出异常让上层处理
+ except Exception as e:
+ raise Exception(f"数据库连接失败:{e}")
+
+ def create_tables(self):
+ """创建表结构(适配 MySQL 语法)"""
+ if DB_TYPE == "mysql":
+ # 学生表:指定 ENGINE=InnoDB 支持外键,TINYINT 替代布尔类型
+ create_student_sql = f"""
+ CREATE TABLE IF NOT EXISTS {TABLE_STUDENT} (
+ student_id VARCHAR(20) PRIMARY KEY COMMENT '学号(唯一标识)',
+ name VARCHAR(50) NOT NULL COMMENT '学生姓名',
+ major VARCHAR(50) NOT NULL COMMENT '所属专业',
+ total_score FLOAT DEFAULT 0 COMMENT '总积分',
+ random_call_num INT DEFAULT 0 COMMENT '随机点名次数'
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生信息表';
+ """
+
+ # 点名记录表:自增主键 AUTO_INCREMENT,外键关联学生表
+ create_record_sql = f"""
+ CREATE TABLE IF NOT EXISTS {TABLE_RECORD} (
+ id INT PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
+ student_id VARCHAR(20) NOT NULL COMMENT '关联学生学号',
+ call_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '点名时间',
+ call_mode VARCHAR(10) NOT NULL COMMENT '点名模式:random/order',
+ is_arrived TINYINT(1) NOT NULL COMMENT '是否到课:1=是,0=否',
+ answer_score FLOAT DEFAULT 0 COMMENT '回答问题加分值',
+ total_add FLOAT DEFAULT 0 COMMENT '本次总加分',
+ FOREIGN KEY (student_id) REFERENCES {TABLE_STUDENT}(student_id) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点名记录表';
+ """
+ else:
+ # 保留 SQLite 语法(兼容用)
+ create_student_sql = f"""
+ CREATE TABLE IF NOT EXISTS {TABLE_STUDENT} (
+ student_id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ major TEXT NOT NULL,
+ total_score FLOAT DEFAULT 0,
+ random_call_num INT DEFAULT 0
+ );
+ """
+ create_record_sql = f"""
+ CREATE TABLE IF NOT EXISTS {TABLE_RECORD} (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ student_id TEXT NOT NULL,
+ call_time DATETIME DEFAULT '{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}',
+ call_mode TEXT NOT NULL,
+ is_arrived BOOLEAN NOT NULL,
+ answer_score FLOAT DEFAULT 0,
+ total_add FLOAT DEFAULT 0,
+ FOREIGN KEY (student_id) REFERENCES {TABLE_STUDENT}(student_id)
+ );
+ """
+
+ try:
+ self.cursor.execute(create_student_sql)
+ self.cursor.execute(create_record_sql)
+ self.conn.commit()
+ print("表结构初始化成功")
+ except pymysql.MySQLError as e:
+ self.conn.rollback() # MySQL 事务回滚
+ raise Exception(f"创建表失败:{e}")
+ except Exception as e:
+ raise Exception(f"创建表失败:{e}")
+
+ def add_student(self, student_id: str, name: str, major: str) -> bool:
+ """添加单个学生(防重复插入)"""
+ try:
+ # 【修复】移除 SQL 语句中的注释,避免干扰参数替换
+ sql = f"""
+ INSERT IGNORE INTO {TABLE_STUDENT} (student_id, name, major)
+ VALUES (%s, %s, %s)
+ """
+ self.cursor.execute(sql, (student_id, name, major))
+ self.conn.commit()
+ return True
+ except pymysql.MySQLError as e:
+ self.conn.rollback()
+ print(f"添加学生失败:{e}")
+ return False
+
+ def batch_add_students(self, students: list) -> tuple:
+ """批量添加学生(列表元素:(学号, 姓名, 专业))"""
+ success_count = 0
+ fail_list = []
+ for s in students:
+ if self.add_student(*s):
+ success_count += 1
+ else:
+ fail_list.append(s)
+ return success_count, fail_list
+
+ def update_student_score(self, student_id: str, add_score: float, is_random: bool = False) -> bool:
+ """更新学生积分和随机点名次数"""
+ try:
+ # 查询当前积分和点名次数
+ select_sql = f"""
+ SELECT total_score, random_call_num FROM {TABLE_STUDENT} WHERE student_id = %s;
+ """
+ self.cursor.execute(select_sql, (student_id,))
+ res = self.cursor.fetchone()
+ if not res:
+ return False
+
+ # 计算新值
+ new_score = res[0] + add_score
+ new_call_num = res[1] + 1 if is_random else res[1]
+
+ # 更新数据
+ update_sql = f"""
+ UPDATE {TABLE_STUDENT} SET total_score = %s, random_call_num = %s WHERE student_id = %s;
+ """
+ self.cursor.execute(update_sql, (new_score, new_call_num, student_id))
+ self.conn.commit()
+ return True
+ except pymysql.MySQLError as e:
+ self.conn.rollback()
+ print(f"更新积分失败:{e}")
+ return False
+
+ def add_call_record(self, student_id: str, call_mode: str, is_arrived: bool, answer_score: float) -> bool:
+ """添加点名记录并同步更新积分"""
+ with open("config/settings.json", "r", encoding="utf-8") as f:
+ score_rules = json.load(f)["score_rules"]
+
+ # 计算本次总加分
+ total_add = score_rules["arrive_score"] if is_arrived else 0
+ total_add += answer_score
+
+ try:
+ # 插入点名记录(MySQL 布尔值用 1/0 存储)
+ insert_sql = f"""
+ INSERT INTO {TABLE_RECORD} (student_id, call_mode, is_arrived, answer_score, total_add)
+ VALUES (%s, %s, %s, %s, %s);
+ """
+ self.cursor.execute(insert_sql, (
+ student_id, call_mode, 1 if is_arrived else 0, answer_score, total_add
+ ))
+
+ # 同步更新学生积分
+ self.update_student_score(student_id, total_add, call_mode == "random")
+ self.conn.commit()
+ return True
+ except pymysql.MySQLError as e:
+ self.conn.rollback()
+ print(f"添加点名记录失败:{e}")
+ return False
+
+ def get_all_students(self) -> list:
+ """获取所有学生信息(按学号排序)"""
+ try:
+ sql = f"""
+ SELECT student_id, name, major, total_score, random_call_num FROM {TABLE_STUDENT} ORDER BY student_id;
+ """
+ self.cursor.execute(sql)
+ return self.cursor.fetchall()
+ except pymysql.MySQLError as e:
+ print(f"查询学生失败:{e}")
+ return []
+
+ def get_student_by_id(self, student_id: str) -> tuple:
+ """按学号查询学生"""
+ try:
+ sql = f"""
+ SELECT student_id, name, major, total_score, random_call_num FROM {TABLE_STUDENT} WHERE student_id = %s;
+ """
+ self.cursor.execute(sql, (student_id,))
+ return self.cursor.fetchone()
+ except pymysql.MySQLError as e:
+ print(f"查询单个学生失败:{e}")
+ return None
+
+ def reconnect(self):
+ """重连数据库(解决 db4free 长时间空闲断开连接问题)"""
+ if not self.conn or not self.conn.open:
+ self.connect()
+
+ def close(self):
+ """关闭数据库连接"""
+ if self.cursor:
+ self.cursor.close()
+ if self.conn:
+ self.conn.close()
+ print("数据库连接已关闭")
+
+# 单例模式:全局唯一数据库实例
+try:
+ db_instance = Database()
+except Exception as e:
+ print(f"数据库初始化失败:{e}")
+ db_instance = None # 初始化失败时设为 None
\ No newline at end of file
diff --git a/class-call-system (1)/src/utils/file_importer.py b/class-call-system (1)/src/utils/file_importer.py
new file mode 100644
index 0000000..56ae1ce
--- /dev/null
+++ b/class-call-system (1)/src/utils/file_importer.py
@@ -0,0 +1,176 @@
+import pandas as pd
+import os
+import json
+import logging
+from typing import Tuple # 核心:导入大写的Tuple
+from src.utils.database import db_instance
+
+# 配置日志
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+# 【核心修复】计算项目根目录,指向正确的config/settings.json
+current_file = os.path.abspath(__file__) # src/utils/file_importer.py
+utils_dir = os.path.dirname(current_file)
+src_dir = os.path.dirname(utils_dir)
+project_root = os.path.dirname(src_dir)
+config_path = os.path.join(project_root, "config", "settings.json")
+
+# 读取配置文件
+try:
+ with open(config_path, "r", encoding="utf-8") as f:
+ CONFIG = json.load(f)
+ EXCEL_CONFIG = CONFIG["excel"]
+except FileNotFoundError:
+ logger.error(f"配置文件不存在: {config_path}")
+ raise
+except KeyError as e:
+ logger.error(f"配置文件格式错误,缺少键: {e}")
+ raise
+except json.JSONDecodeError:
+ logger.error(f"配置文件解析失败,不是有效的JSON格式: {config_path}")
+ raise
+
+
+class ExcelImporter:
+ """Excel导入导出工具类,处理学生信息的导入导出"""
+
+ def __init__(self):
+ # 确保模板目录存在(基于项目根目录拼接)
+ self.template_path = os.path.join(project_root, EXCEL_CONFIG["template_path"])
+ template_dir = os.path.dirname(self.template_path)
+ os.makedirs(template_dir, exist_ok=True)
+ # 生成Excel模板
+ self._create_template()
+
+ def _create_template(self):
+ """创建学生名单Excel模板,包含示例数据指导用户填写"""
+ if not os.path.exists(self.template_path):
+ # 添加示例数据便于用户理解格式
+ template_data = [
+ ["学号", "姓名", "专业"],
+ ["2023001", "张三", "计算机科学与技术"],
+ ["2023002", "李四", "软件工程"]
+ ]
+ df = pd.DataFrame(template_data[1:], columns=template_data[0])
+ try:
+ df.to_excel(self.template_path, index=False, engine="openpyxl")
+ logger.info(f"Excel模板已生成:{self.template_path}")
+ except PermissionError:
+ logger.error(f"没有权限写入模板文件:{self.template_path}")
+ except Exception as e:
+ logger.error(f"生成模板失败:{str(e)}")
+
+ def import_students(self, file_path: str) -> Tuple[bool, str]: # 修正:Tuple[bool, str]
+ """
+ 从Excel导入学生名单并验证数据合法性
+
+ Args:
+ file_path: Excel文件路径
+
+ Returns:
+ Tuple: (是否成功, 结果信息)
+ """
+ # 基础校验
+ if not file_path:
+ return False, "文件路径不能为空"
+ if not os.path.exists(file_path):
+ return False, f"文件不存在:{file_path}"
+ if not os.path.isfile(file_path):
+ return False, f"不是有效的文件:{file_path}"
+
+ try:
+ # 读取Excel
+ df = pd.read_excel(file_path, engine="openpyxl")
+
+ # 列名校验
+ required_cols = ["学号", "姓名", "专业"]
+ if not all(col in df.columns for col in required_cols):
+ missing = [col for col in required_cols if col not in df.columns]
+ return False, f"Excel列名错误,缺少必要列:{missing},需包含:{required_cols}"
+
+ # 数据清洗与校验
+ # 删除空行(包含NaN的行)
+ df = df.dropna(subset=required_cols)
+ # 转换为字符串并去除首尾空格
+ for col in required_cols:
+ df[col] = df[col].astype(str).str.strip()
+
+ # 校验空字符串
+ empty_rows = df[(df["学号"] == "") | (df["姓名"] == "") | (df["专业"] == "")]
+ if not empty_rows.empty:
+ return False, f"存在空值数据,行索引:{list(empty_rows.index + 2)}(注意Excel行号从1开始)"
+
+ # 校验学号重复
+ duplicate_ids = df[df["学号"].duplicated()]["学号"].unique()
+ if len(duplicate_ids) > 0:
+ return False, f"存在重复学号:{list(duplicate_ids)}"
+
+ # 准备导入数据
+ students = list(df[required_cols].itertuples(index=False, name=None))
+ success_count, fail_list = db_instance.batch_add_students(students)
+
+ # 构建返回信息
+ result_msg = f"成功导入{success_count}名学生"
+ if fail_list:
+ # 兼容批量导入的失败详情展示
+ fail_details = [f"{s}" for s in fail_list]
+ result_msg += f",失败{len(fail_list)}条:{'; '.join(fail_details)}"
+
+ logger.info(result_msg)
+ return True, result_msg
+
+ except PermissionError:
+ return False, f"没有权限读取文件:{file_path}"
+ except Exception as e:
+ logger.error(f"导入失败:{str(e)}", exc_info=True)
+ return False, f"导入失败:{str(e)}"
+
+ def export_scores(self, file_path: str = None) -> Tuple[bool, str]: # 修正:Tuple[bool, str]
+ """
+ 导出学生积分详单到Excel
+
+ Args:
+ file_path: 导出文件路径,默认使用配置中的路径
+
+ Returns:
+ Tuple: (是否成功, 结果信息)
+ """
+ try:
+ # 拼接默认导出路径(基于项目根目录)
+ default_export_path = os.path.join(project_root, EXCEL_CONFIG["export_default_path"])
+ file_path = file_path or default_export_path
+
+ # 确保导出目录存在
+ export_dir = os.path.dirname(file_path)
+ os.makedirs(export_dir, exist_ok=True)
+
+ # 获取学生数据
+ students = db_instance.get_all_students()
+ if not students:
+ return False, "无学生数据可导出"
+
+ # 验证数据结构(确保包含所需字段)
+ required_fields = 5 # 学号、姓名、专业、总积分、随机点名次数
+ if any(len(student) != required_fields for student in students):
+ return False, "学生数据结构错误,无法导出"
+
+ # 构造DataFrame并导出
+ df = pd.DataFrame(students, columns=["学号", "姓名", "专业", "总积分", "随机点名次数"])
+ df.to_excel(file_path, index=False, engine="openpyxl")
+
+ logger.info(f"积分详单已导出至:{file_path}")
+ return True, f"积分详单已导出至:{file_path}"
+
+ except PermissionError:
+ return False, f"没有权限写入文件:{file_path}"
+ except Exception as e:
+ logger.error(f"导出失败:{str(e)}", exc_info=True)
+ return False, f"导出失败:{str(e)}"
+
+
+# 单例实例
+excel_importer = ExcelImporter()
\ No newline at end of file
diff --git a/class-call-system (1)/src/utils/helpers.py b/class-call-system (1)/src/utils/helpers.py
new file mode 100644
index 0000000..b9c81a6
--- /dev/null
+++ b/class-call-system (1)/src/utils/helpers.py
@@ -0,0 +1,46 @@
+import json
+import random
+from datetime import datetime
+
+# 读取配置
+with open("config/settings.json", "r", encoding="utf-8") as f:
+ CONFIG = json.load(f)
+SCORE_RULES = CONFIG["score_rules"]
+VIS_CONFIG = CONFIG["visualization"]
+
+def format_time(timestamp: datetime = None) -> str:
+ """格式化时间为字符串"""
+ if not timestamp:
+ timestamp = datetime.now()
+ return timestamp.strftime("%Y-%m-%d %H:%M:%S")
+
+def calculate_weight(scores: list) -> list:
+ """计算随机点名权重:积分越高,权重越低"""
+ weights = []
+ for score in scores:
+ if score >= 0:
+ # 正积分:权重=1/(积分+1),避免除以0
+ weight = 1 / (score + 1)
+ else:
+ # 负积分:权重=绝对值+1,提高被点到概率
+ weight = abs(score) + 1
+ weights.append(weight)
+ return weights
+
+def get_answer_score(level: str) -> float:
+ """根据回答等级返回分数(简易版,可扩展)"""
+ level_map = {
+ "优秀": 3.0,
+ "良好": 2.0,
+ "及格": 0.5,
+ "错误": -1.0,
+ "重复正确": SCORE_RULES["repeat_correct"],
+ "重复错误": SCORE_RULES["repeat_wrong"]
+ }
+ return level_map.get(level, 0.0)
+
+def get_visual_top_n(students: list) -> list:
+ """获取可视化的TOP N学生"""
+ # 按积分降序排序,取前N个
+ sorted_students = sorted(students, key=lambda x: x[3], reverse=True)
+ return sorted_students[:VIS_CONFIG["top_n"]]
\ No newline at end of file