+ {{ errorMessage }} + +
++ {{ lastError }} +
+ +mI|13!gBV?X`Ozp7x>?6jr`>Qz=^4ea35!$*f}) zS$i+x_k+@P2q1RFUH^ZTTk7=n?cjfR>hTq3l3SY~#w+I8SSutXGyhw;Ws~=zMQ%Vc z>$On~47Ut?P*_!TOQ&PFmLAyJieB2X4_Fd_!WxI-AY`q1Lc-oK?+qcOTzlQ?@~x@OT}*9jTVNfl@3rGvZpWI=eKg>T zZb@6YWz)J=IhP7CF|c?G62vMEG%#U}?#86$0jR4sG~i(jRd#jmn`7b(O#?N;3a;1t zhXLssmUwGhp79luw#(*V8W L0|8+E z6=YZ_O@er~$LrD_PYGc(kJgB=;yw#+Z3X6LDUZ(NcwN=B-hjdiHm!JFar%m{(5bEW z@@_VEtG$5;`EJZ|OkJ@l&G9n((w@uNFwmU%bG|s#TbcJJos!{e +bjCjrCq_}LcN!UFgKtgg7siV*7# z!}1whTRRi*-avJPu->C}Z8EiuK$#886+H_#_!btv+rsiBbv2jAJvJ+O0{#}y(%L3H zfjU-kq_-L@2XrL*ae{{qYJkD{@dw%*bkh2P&YS-0!Xt!PRz7KHV0+~j(t9W8lAVWR zt@B*DgURgEz4>WuN>o?_iKc w$?k{||Pg7{Q2o4|VmJ)mg?{VQJA<}zEr^YAAS zgGm5RT4T3p)U;yz-tfBO^kw8?IoG!IVmc+Z3m#}AOQ?5MRa>)OcU!$N^_+yK6ayn? zK>~WK0!#ysuj^oNLakm)Zvu+J)OSubX^kv!c*xgdIvs;kln!rgG4*uZ;w0mQQO4XD zO9P{GNdv!=cQ(CAL{S(% KtuV^zC& Q{%g)PoXnp^gn^>c*`E>$hLYg2HjnbVGtWLa{7zHdG1jT@B{|Dm16 z7K2(jsfG+m*Zxof)iXxu+!H5M o-0$pkyV3VV4B@Qms46M zuBxGRV@HxU 7Wwx-6CB zaU*HO <_qn$5GH>&@?nRy1{z zkik!sLfWQ)r#75)vVwCBU*r_)Q6mp?!j85{#Xqse)ApRdE$V0%I0*~e(_{)5H)`Mk z#rExC>yjhZxuL@|+#v4#< Axw$+VpV zuT;!2Vww$je$DpAW`$FX_Ab|Ip%$;&T$-lW8jS~B$>G}rd>eQG+$h9lQx4Mx0w={m zx9?T6VU`>sR}XC lkAhHEShOUe8awiq zmizhL+}5UKs3}6~It7vBTig9dfQ2Q8coo+Miiaw7n~>4ybv2Ptt0^^=VqX(t*Yya9 zr`FxxFX8(v*H=+uJ#JJWIB2A(==HDYx~^zZ2nu?2`}|Wsa*f3h3ixc+U|FDtAG$Y! z*lc_7se5Oso-Cgqe0){{!8H4g$3<8!R<6JOurD;((({c$1(pwb>(#TT!sge@4>r2@ zVL7>U`0`nsWAYErezk4(Z!gMI2?UTo{J3Ajo(u4)KYIRd>BRcG4BoS3G0EXyEp@tw z%P7__?A^a>Q&AKL@ayDO9D*Qkc!NHnO9l}kpp_6hXbMppYL(X1L?njdFT|-h2<_$; zAtDZ!1Rf%|yb!qbWKd}%0b`LzBeyNy43|QO(&h2mxQLUL)|0%agVOW)6TV!&Ip^Ls z`PG2cygM8)IecQx=Fc+nqYRo4hS^^-nM_&-y8?EJXUczP=DIw(GkTJdpEdh<_STs{ z|A)4n1GKdE=Wu!!nYoZHcUQ4S&R;oDOKX2lrkdF(mK>hz<$Pp>igjOcvoRIjlN=W8 zu8Gx5(roqn8$>gEE5vy{GiGeW8Tq{vnf3hS-V=$tZkQuftUVuU8o6k&dn= Yg3)6MOIH>nlK^-2+C6BZITr~1@So?NvG#TwL)|~=1YXGMTLpS<)ziK_CSOabe z=cB#5)yz|@0i9dSo?*CX)}UP=s6)B+F@~Em(u@Q(I9J9i_V{LmMu8BfXYMh~*oPP+ z!3~xTv|(>|=n6ZOtT~C@V!z!w%18*8T2t6}U2S##rC)mekBql&VsBX;$~ByGE$oA9 z`0Wzq8p?R{4)$l*on;!cLa}Dh^Xe?owiQZt9nH1fxxh$pN9K%CtOw?u3>85L7rr!d zXs)l{TZ{xXP&U8exz?9cv~dNNibOmt*K4I$?RxqIBZ0(?Mg-9FS{*9Bc49Qc1`=sIF-rye`aNT1G@4NwXcnyc@+bw_mTsR>5< zF<2;X0QesG_pw|TonqVBhRtfqI>ty(SIu&VOXd0CrLlfp+;WH7HYjhqnu^oAY!9cB z=B6#R?Rfz9BP`dJ=@v_?70s3HxQPk+{6Y+lM85f2NF^00*^OcM0~?JOZfR9ZPYF+# zYSs}(_BUYV8{n@2a1hD^SV41bwmi2uztR;PeBgF1F-`9>` zoNss-@3LaF2sjl~>OaaVmp7PNp+UT`6@}gR%uzqHDVeEZ14{Yt?n%JeQm+t(1_u zSc}oj^{b;+rlS|ME%+LjzSI&xu0Bblxo$MJ-J$kJ?Qu_XUXh}*@*-x@ny|}wVM%Lg z3tNB`yvr*}N?ClGL;H2cglcvErIccU3(eP7>@~4nOIcI~-`P8tSQnx=jI&{9)!1}l z;gQ%_h>ZlPSV@o@Azq1 R$C6ja5!^ZGh;YRhhxs58qJWo9@Bc eac&yy(pET1hnn`~7@}2L0&dfPKYs$ih7m2}R!25!(hxqA(!UIw; zK4+~Jowy3=R NC6n E=ncU{LH5?*9@W24lacJlvCZXB$CYtE@>c+~H zkV=(5I&gb{xn2!~f&fs2NQgAL6`p|kyt6kpWk}iVlqIp(H;ig`{_U9yxs1jzu^ETM z7~)Rg8C-Nue qTYP&U8l{DY=Y47cR zOR@U%$KQV{mkRF|4)z9Y^t3K`@p>duY&QLUFeh6VoV`a`$U@)(z!-N*5Cj<1 1$EZW&hJLX83TO{lJYP74rlDZQPkm@t<=U^I)x@|UnHHkdQlh?!ltZwl92rE;;^ zZuIappj4dhld1}kttYYV-j|KF1Kus zWBnzttD^00%LFK(wrwNragFub6xiV8QE2rm<`&fcR4SLFcdtLxVuN!Aal-g6dE4%k zARZ}|xeo;K{0yf7@9aua%2j5o)CPcIOc6uLHFJOcgtB5owlcNAwyAHc0QB0Dts?c@ zUemG~j_E&W7R%+x-IO4FJl8e&*2Blmp1S#RA|)geVrxvP)NHdYuxi~g&Etn?QdN K8ZDKZ?QFLU? zh30 G|t9G>a_X4zk}Ygw<^$7K!GIn(Io$>(d4ODJQ2XSd%jpK zm7>ptl$a3GyB}5-%p4>Q*p#VL^B{yQMuFCM^#l#+N!Ne z5_PrJWB=@Iy+t)H`g1lX`{bm($KE5I?0c(JEYm#t{F}j!xtsb ob0{xu@0TB_*>G7w0ICn zr#V oBktqHZ~XxhiKD*lcG|b;H *|Ny3P^8ceV`sfBRfrhwZ!T+MFZ!F1Bt{q$8d9i6o?~ zODj^POr}&ivSa^R^YFIq7o0giLBKCycH_aU`F6)O6JX%nPTwh~Q`eq6*0iE#Srj2^ z*_hN3%*b83zfafy60@Cp3{J({RlSaEn &E?mrxRNC9GQ7#+f=s! z0KBf-9Ny_v2VbE%aB|Di)5kNJ^t&C`4D(>t7zYUWUFtbxt+Oq=!@O7BU)}>d*R72o zFF)3jQD_lLe4is&xzyJYC1-c{8TX$RU>&>P$%)ufpez0XSAukmh!xcekg`s$c<>-q zI#zn^JU0zzF}V60)o$_gY}PQH>b2M9&8fR Za#OauglPb zeQ@pMm&=!vNgos4CluQjLMV!pfkmxK+35bi^k&=k>9h02?l+u+m0agG ;(h2|Jslc-llvtEwn~*w3bx7qnvZACG<8}AGeaDVvcHbKd2>3G^ zSFPULUn-?Pmo^-_`mLZr??uNH`2=I&yajlrF{DtUxMy#Nu}z=3y7qbUA;5`)hibMR zhXL@@uKyV0-2&A@t@!xyrBnMJl&^o@Gx$&5_q6?D=ji5grd-~=?dlg;ur(_V0wjh! zA=JV^C1m+DDkOsgr<%O9ZQFg!0}pD(#PSz4Dr_EyS5$`)VIAv);4n-SFP~YtC7sH= z7&*MfpH;gd*FHbkmD#)hVxb6xjc9~`t?_{=JS+@ip_cTicXxG<=7m9 & zPX+Z8IC*GSAXuGCrZDHgR$r%jyk-fctis2Kx4HvZ|B~8uC@o)m^>Hy-O!&TKA?$&n zkP2Xc54w~!=z2?^NafyL*L0V9cbYrugHBBUj`xVyZmGFR&kvk#>1J*Z~i zNTz}?IAdJ$gkqd2!Gw(%LzE!O5s4C7q4%T~e_P{+z=DNDKrG**p=U`d5yg^vp`;Zn zsU=8gd0a9s4s0FPJePWR9eH5=+O^Kks&kC-iblNqTh2&Pw*^(4384f+D8N|fewZu_ zg2ejQ)ov;ztz;NQl7yj;A`(!H!XQu_$sqY9h_IrH*}_%1{L&_YLDvO?%R5Z-t+ClW z_qERbL?HKUZ!nt+!E9S`uoh^5A|DaIHe*_gf1`E_Vq+}{&T@t$EGhMnRjJ4z2w_W8 zp+qjs7as22^&S3wY1?+}^j-I=RcCE>#|39)g( lU7v_8;?=qK(9D8-*pPdiy)P3lIblG`+?%ea| zYoD3dopYt!tKgFicfNmNi(EWE=E4hC6(r|PYtanqJlmt57YOVrr2^tfrG(eG9C##X zu&1t@%L$RIvpj!w UA z8i>Pqot#_+Cnp6L2XPcZy1ar|9MnY+7eNvK1E)@Tr#2KsXq1*>)uUCozT7L##ok?o zhA6ofP4E|b*9tAfG?u f$#}>TIR&1A!yslP8}i7w-EzW(x#9VEvx18k%Tn=-$VV zkOtUr0b2!w3t>h?#8AZl^Az*(6KCGlD;4j~yx};`#2gN1_gv=%7KVzecIRakN{f*4 zeaI>yH;-o4OGhvGTU)(quWI )-q?V*(sVesSMv|wMUQ3hLEt=lBB$KZ9TyHr> )f7o%) zPYeU<3P)*P10*7vE)nA5#{c=6-E-_>r_u4e3i!I2+UksELwDqwMeBZ9FSP$;^Ajro z_@M#_Ss$?ejoB@!wN|kbGKs(0zLo%0QpQXW#t;oC$B0MZYZ&Ej?8~fNhcCVvPo3vo zFn0WWZaPliF^8_}yzb`*f@yg0uWv6HgNI)xa=pO%Ck(C<=-60l#uD3(wXP~c7!NoX z0&^6=N`zcc90F#qt@=Rn@r!3(*1v(Tl{B!m?Mc7yIA+nEHpY{YWr$=)F7rhR1P}(v zt{YhY#;jsW6G>#xhP*B`OCk|Pf+NN;ju1rxa*HAgoGq*rvqw&xe~;t1JA31$s?GBb z*g7&@cbKo4n<`>)!UlIAgR6q&))B0KYU8r66GbFj?8Guw4E%&}Qi_lT003LtoIZei zwD~=XZmeo+yZ2Pq3KYCF-R&11^p = z@H%s+=G`}wrbJ{()Mh71#2SP3Zy3m>l1n?0N-N1Q;z6?oSxr-G(H5m4EO>~&;}VKi zfY}3w+9z> vp#d)hVuu`)vG_aaH%3b=WKMnSu&c31;<3O;bz2iD=w+o4#oBb36 z5ZCF*Gu?zjZIR0S>_%pHY2$k8D^n7Sz_K8tCDeXM+dO<#LSg%h6`~dnVG1N@T7v&e z%wEd1!k{^zfz_1BTW{!$!B%g)J^2b87!9Y>>100X1SgT7s0z$o>^lAA=G p_cC1(h=*5Tmf8z&LGJJ>$|K^~s`z9*OWz5MFUr?>Bi?_PGBB)#psD5?>n+q{o_ zz7~ez&;t#h8l$jwGPCC&xq2YetXYQT+0F3j(`xmNGf8dj#an|p#I*pvI*kwW4iuB> z+q3_7xB8y;pLzHG-S%+UHQ A zvqp;$kmGJY>lL sN4C~&TcvAS1SErTcwcw0r@wngk zShAUA1M9b#g}^pL-zH7Q#z^&j#r9F8BTVfkR&qF<=e35goTu7c|GN)0mokj4m0%~0 zXJ8j4Hc_l;HJ&uU*Iw`8d_EscJ``s0tk9mkKo^TYXm-EoAzTQObxa@^u~g2t#T) zJz|rE!I_?i4dCJC=B8(_pZ{YR>|V?0iCcnU;E@$239^x?SYCfNaMHN;CtHIS_zHN9 zTkQc1v@O35okiFtq5_u+5FkY55ap@pi)O?}x0D1c*qB0KpY R}>Ul+B0Vmr}Z@+%mJ|As}sis_=ROPbov@*2thpE&?!V#Qgu$snYvCZ zrkhmkMU+fSf-s8(L37 fPr&M*jRs{{THb!aXQu|P9l_-vJhHvLzMGH zE?1U0H_+Pm NABp9`|KzkGfrrZ%XvdGo6*<{d5m9~L7 z_ ^`M;X6xDo=m6LY6RfvJEvsTK1!u8d2HPx| $S}p;sRy!I zWL55Yxu~_B`OP@~(q6&W3#)~I&+MGL%GWR$#udC151^wsswhqlii;rP9jJpiI7o&Z zAb})=HY7?4HA|re3ns`%$)FuvKCFWjhb~?IE)F6dF2K5}poj-NK6Gf;hw$t3=1txY zoxQxZWrQU6K!%|~!m?~Bnw-6Rr!F3BZ{u5!LqnZTDON}Coj9^@&le)V!NYrVwS~B% zEL+>Sr@}qGwGvu|HrOo|gSt__ezN^&%~{*)a=rf7y1HujUcr`zZB<4#l@T#eN)si} z)lZA<{=tKx8E%c9>A(##6 }_p+~EZpKsl5a4pj`E*;_-6`ysiv zffA!7=MT1vCz}-m4~tjVey1b2KSR4OEtLd-(_DdUqYZ74LaDkhH?KFh?%WAOP2WbX zp@zT+Dx|5_f%JQiAGvVw!oh +g3e50u!aPfMxdC=E)XB{F5IcEZhePIM- zph6Y`$Oy?JBL<8Ex(SqEhLeQ@XcrdA>a?rx+_~HLA;l14)WmmpH}_w?Pg#HBZs0eS zwypwAW?M-x+3AU-(GGWSJ=ngxUEcEZ5OsX(Qlt !MQ zn^(`S{GHkAv(8@D`EAfSYig%Cxv?z!{=w^F#y)5_d7FuKZH7qlR-#5B0bt806%D0I zT7VdVP_?q*%Rq8UR;JkD4i^RXowt+E%#V2U>TfDqzZSDZ+dR!a#T3I>-z_$q9@k|m zy5~A*m~&JWP@E7a=pc}4kVHTc4h&R;Li7d@f`|hKMLkbb^uhOakNr3&FLj lm~i5NBM< zFaYI{;cpiHCNRdE0dg*>qIm(_t?#$h=(SCw?h3rJV2*ER8{O4^3#=dO)KwklZkoqU zS8i5c%YL*y*4;FY#D=XmkQnYj%LH)?02~gSJH`Qp1XY64g>%c_K$xseI&|e)7vRoL zAqRba$G@%fSGA7X7hQk%_3NVOYVS+$leU_!&6*5uN)8#5ZBz_6ASCA;azYS-Rt@ki zg2NWz(=;t}SC(~Ibl63$5C8FPmhXqb^)5#jaJ~I{Ex3xZ!+2h8$}}h_g@Be>HZ;72 z6#y#>AY3^skuVKF#0WxFBQ()5d5_nWb?c6c>EeMM|Mh+*&wEpPyxHCq{R-Gdr-`hN zF=1sxl&mBoK+#qRLl9#CEN|Fg8>nbmsTg3a1;#M9enQ$RgWk}kp#-5wh=EF&1tl%mJln2V^8o%Qv(*=zEuO7y z=m*8?xpUn-*@h5Cl_3BK3joiGkyaScK+>|MWdMRWm@RT!Q1piAlv5hL@B6>3&GI8) zP!xBc6}ZNIpJLL%2a8Y!+(<=f%WX>_uWVxlga9!D*oYt$l0cxRDMvqfU;Kq_mLK5k z)dvqYcgLa_Lz?3HyeF)@$%$&6lI?r4I>6W#M*<)vq{?& Oqrx``d`mhpVPr> z#q078F6gw_X<=?KR>8%^t%@wbITvNMu!hKiTSkCTJkw>1!e*Y{%31#_yMf=LW7{RJ zYoC^w$6%3cBtVG5)x#{Hg6IVTh9XEcM{gQwXk!R^y95^f-hZ`d{aVa+xW1EO4wDV4 zB?JgD7*?qkvc|$nIykTvNl2x0j3Q!MXoLL^)~}d7jcYf(H8D~c+?$pKL(px>Z3`eb z04RzS6_AgFT6Pn#iZAg$Sl_j8#;6ShF%&(Fag#E2asU@@LaN;=b=Wf7sgPKhfzhBM zC@eFL8^MrnA*9&Khe*Ab@CC9*uyJGXyi(;y2>lQLJZt;ShtJi?3Yf_t `F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4 zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+ zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f= P00000NkvXXu0mjft=yBf diff --git a/src/assets/vite.svg b/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/assets/vue.svg b/src/assets/vue.svg deleted file mode 100644 index 770e9d3..0000000 --- a/src/assets/vue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/A4Workspace.vue b/src/components/A4Workspace.vue new file mode 100644 index 0000000..08114cb --- /dev/null +++ b/src/components/A4Workspace.vue @@ -0,0 +1,37 @@ + + + + ++ diff --git a/src/components/CoverPage.vue b/src/components/CoverPage.vue new file mode 100644 index 0000000..526880d --- /dev/null +++ b/src/components/CoverPage.vue @@ -0,0 +1,40 @@ + + + ++++ + + + diff --git a/src/components/EditableMarkdown.test.ts b/src/components/EditableMarkdown.test.ts new file mode 100644 index 0000000..2ca76ea --- /dev/null +++ b/src/components/EditableMarkdown.test.ts @@ -0,0 +1,15 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import EditableMarkdown from './EditableMarkdown.vue' + +describe('EditableMarkdown', () => { + it('renders markdown when blurred and edits raw markdown when activated', async () => { + const wrapper = mount(EditableMarkdown, { + props: { modelValue: '**重点**内容', label: '教师活动' }, + }) + + expect(wrapper.get('.markdown-preview strong').text()).toBe('重点') + await wrapper.get('.markdown-preview').trigger('click') + expect(wrapper.get('textarea').element.value).toBe('**重点**内容') + }) +}) diff --git a/src/components/EditableMarkdown.vue b/src/components/EditableMarkdown.vue new file mode 100644 index 0000000..240ded6 --- /dev/null +++ b/src/components/EditableMarkdown.vue @@ -0,0 +1,79 @@ + + + +教学设计
++ 课程名称 +++ + 教师姓名 +++ + + ++ diff --git a/src/components/EditableText.test.ts b/src/components/EditableText.test.ts new file mode 100644 index 0000000..a9a646c --- /dev/null +++ b/src/components/EditableText.test.ts @@ -0,0 +1,16 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import EditableText from './EditableText.vue' + +describe('EditableText', () => { + it('emits updates while keeping an accessible label', async () => { + const wrapper = mount(EditableText, { + props: { modelValue: '旧内容', label: '课题' }, + }) + + await wrapper.get('textarea').setValue('新内容') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['新内容']) + expect(wrapper.get('textarea').attributes('aria-label')).toBe('课题') + }) +}) diff --git a/src/components/EditableText.vue b/src/components/EditableText.vue new file mode 100644 index 0000000..543aad8 --- /dev/null +++ b/src/components/EditableText.vue @@ -0,0 +1,59 @@ + + + + + {{ modelValue }} + diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue deleted file mode 100644 index c232865..0000000 --- a/src/components/HelloWorld.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - -- - - - ----
-
-
-- -Get started
-Edit
-src/App.vueand save to testHMR- - - - - - diff --git a/src/components/ImportConflictDialog.vue b/src/components/ImportConflictDialog.vue new file mode 100644 index 0000000..75be27a --- /dev/null +++ b/src/components/ImportConflictDialog.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/src/components/LessonSidebar.test.ts b/src/components/LessonSidebar.test.ts new file mode 100644 index 0000000..1479cab --- /dev/null +++ b/src/components/LessonSidebar.test.ts @@ -0,0 +1,18 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { createEmptyTeachingDesign } from '../domain/teachingDesign' +import LessonSidebar from './LessonSidebar.vue' + +describe('LessonSidebar', () => { + it('emits a move when one lesson is dropped on another', async () => { + const designs = [createEmptyTeachingDesign('1.md'), createEmptyTeachingDesign('2.md')] + const wrapper = mount(LessonSidebar, { + props: { designs, selectedId: designs[0]?.id ?? 'cover' }, + }) + + await wrapper.get('[data-index="0"]').trigger('dragstart') + await wrapper.get('[data-index="1"]').trigger('drop') + + expect(wrapper.emitted('move')?.[0]).toEqual([0, 1]) + }) +}) diff --git a/src/components/LessonSidebar.vue b/src/components/LessonSidebar.vue new file mode 100644 index 0000000..450c8f2 --- /dev/null +++ b/src/components/LessonSidebar.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/src/components/PrintBook.test.ts b/src/components/PrintBook.test.ts new file mode 100644 index 0000000..9aeb748 --- /dev/null +++ b/src/components/PrintBook.test.ts @@ -0,0 +1,23 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { createEmptyTeachingDesign } from '../domain/teachingDesign' +import PrintBook from './PrintBook.vue' + +describe('PrintBook', () => { + it('renders one cover and every lesson in current order', () => { + const first = createEmptyTeachingDesign('2.md') + first.topic = '第二课' + const second = createEmptyTeachingDesign('1.md') + second.topic = '第一课' + + const wrapper = mount(PrintBook, { + props: { + cover: { courseName: 'Web 前端开发', teacherName: '张老师' }, + designs: [first, second], + }, + }) + + expect(wrapper.findAll('.print-section')).toHaveLength(3) + expect(wrapper.text().indexOf('第二课')).toBeLessThan(wrapper.text().indexOf('第一课')) + }) +}) diff --git a/src/components/PrintBook.vue b/src/components/PrintBook.vue new file mode 100644 index 0000000..0e8ef98 --- /dev/null +++ b/src/components/PrintBook.vue @@ -0,0 +1,25 @@ + + + +++ diff --git a/src/components/RestoreDraftDialog.vue b/src/components/RestoreDraftDialog.vue new file mode 100644 index 0000000..685caca --- /dev/null +++ b/src/components/RestoreDraftDialog.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/src/components/TeachingDesignPage.test.ts b/src/components/TeachingDesignPage.test.ts new file mode 100644 index 0000000..917d22e --- /dev/null +++ b/src/components/TeachingDesignPage.test.ts @@ -0,0 +1,18 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { createEmptyTeachingDesign, type TeachingDesign } from '../domain/teachingDesign' +import TeachingDesignPage from './TeachingDesignPage.vue' + +describe('TeachingDesignPage', () => { + it('adds and removes teaching process rows', async () => { + const design = createEmptyTeachingDesign('1.md') + const wrapper = mount(TeachingDesignPage, { + props: { design, editable: true }, + }) + + await wrapper.get('[data-testid="add-step"]').trigger('click') + expect( + wrapper.emitted+++ +++ ('update:design')?.at(-1)?.[0]?.processSteps, + ).toHaveLength(2) + }) +}) diff --git a/src/components/TeachingDesignPage.vue b/src/components/TeachingDesignPage.vue new file mode 100644 index 0000000..d13627a --- /dev/null +++ b/src/components/TeachingDesignPage.vue @@ -0,0 +1,286 @@ + + + + + + diff --git a/src/components/UploadDropzone.test.ts b/src/components/UploadDropzone.test.ts new file mode 100644 index 0000000..6f8cda1 --- /dev/null +++ b/src/components/UploadDropzone.test.ts @@ -0,0 +1,16 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import UploadDropzone from './UploadDropzone.vue' + +describe('UploadDropzone', () => { + it('emits every selected file', async () => { + const wrapper = mount(UploadDropzone) + const files = [new File(['# one'], '1.md'), new File(['# two'], '2.md')] + const input = wrapper.get('input[type="file"]') + + Object.defineProperty(input.element, 'files', { value: files }) + await input.trigger('change') + + expect(wrapper.emitted('files')?.[0]?.[0]).toEqual(files) + }) +}) diff --git a/src/components/UploadDropzone.vue b/src/components/UploadDropzone.vue new file mode 100644 index 0000000..79fd379 --- /dev/null +++ b/src/components/UploadDropzone.vue @@ -0,0 +1,77 @@ + + + ++ + + +
+ ++ +课题 ++ ++ + +课时 ++ ++ + +教学目标 ++ ++ 知识目标 +++ + 技能目标 +++ + 素养目标 +++ + +教学重难点 ++ ++ 重点 +++ + 难点 +++ + + +教学资源准备 ++ ++ 教学过程
++ +
+ + ++ + + +教学环节 +教学内容 +教师活动 +学生活动 +设计意图 ++ + + ++ ++ + + ++ + ++ + ++ + ++ + + +板书设计
++ + 教学成效与反思
++ +
+ + ++ +教学成效 ++ ++ + + +教学反思 ++ ++ 附加内容
++ + + +
+- {{ warning.message }}
++ + + 导入教学设计 + + ++ diff --git a/src/components/WorkspaceToolbar.vue b/src/components/WorkspaceToolbar.vue new file mode 100644 index 0000000..4730d11 --- /dev/null +++ b/src/components/WorkspaceToolbar.vue @@ -0,0 +1,40 @@ + + + +点击或拖拽上传 Markdown 教学设计文件
+支持批量导入多个 .md 文件
+ ++ + + + + + + + + + diff --git a/src/composables/useTeachingBook.test.ts b/src/composables/useTeachingBook.test.ts new file mode 100644 index 0000000..a7b0fcf --- /dev/null +++ b/src/composables/useTeachingBook.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useTeachingBook } from './useTeachingBook' + +describe('useTeachingBook', () => { + beforeEach(() => { + localStorage.clear() + vi.useFakeTimers() + }) + + it('imports files in natural order and selects the first lesson', async () => { + const store = useTeachingBook() + const files = [ + new File(['# 第十课 教学设计'], '10.md', { type: 'text/markdown' }), + new File(['# 第二课 教学设计'], '2.md', { type: 'text/markdown' }), + ] + + await store.importFiles(files, 'keep') + + expect(store.book.value.designs.map((design) => design.originalFilename)).toEqual([ + '2.md', + '10.md', + ]) + expect(store.book.value.selectedId).toBe(store.book.value.designs[0]?.id) + }) + + it('reorders lessons without changing their identities', async () => { + const store = useTeachingBook() + await store.importFiles([ + new File(['# One 教学设计'], '1.md'), + new File(['# Two 教学设计'], '2.md'), + ], 'keep') + + const ids = store.book.value.designs.map((design) => design.id) + store.moveDesign(0, 1) + + expect(store.book.value.designs.map((design) => design.id)).toEqual(ids.reverse()) + }) +}) diff --git a/src/composables/useTeachingBook.ts b/src/composables/useTeachingBook.ts new file mode 100644 index 0000000..8acceeb --- /dev/null +++ b/src/composables/useTeachingBook.ts @@ -0,0 +1,226 @@ +import { ref, watch, type Ref } from 'vue' +import { + createEmptyBook, + type BookCover, + type DesignId, + type TeachingBook, + type TeachingDesign, +} from '../domain/teachingDesign' +import { saveBook } from '../services/bookStorage' +import { parseTeachingDesign } from '../services/markdownParser' +import { sortFilesNaturally } from '../services/naturalSort' + +const AUTOSAVE_DELAY_MS = 300 + +export type DuplicateStrategy = 'replace' | 'keep' + +export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' + +export interface ImportResult { + imported: number + failed: Array<{ filename: string; message: string }> + duplicates: string[] +} + +export interface TeachingBookStore { + book: Ref+ saveStatus: Ref + lastError: Ref + pendingDuplicateFiles: Ref + selectedDesign: Ref + hasDesigns: Ref + warningCount: Ref + importFiles: (files: readonly File[], strategy: DuplicateStrategy) => Promise + detectDuplicates: (files: readonly File[]) => string[] + selectPage: (id: 'cover' | DesignId) => void + moveDesign: (from: number, to: number) => void + removeDesign: (id: DesignId) => void + updateCover: (patch: Partial ) => void + updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void + restore: (book: TeachingBook) => void + clearBook: () => void +} + +export function useTeachingBook(): TeachingBookStore { + const book = ref (createEmptyBook()) as Ref + const saveStatus = ref ('idle') + const lastError = ref (null) + const pendingDuplicateFiles = ref ([]) + + const selectedDesign = ref (null) + const hasDesigns = ref(false) + const warningCount = ref(0) + + function syncDerived(): void { + const current = book.value + hasDesigns.value = current.designs.length > 0 + selectedDesign.value = + current.selectedId === 'cover' + ? null + : current.designs.find((design) => design.id === current.selectedId) ?? null + warningCount.value = current.designs.reduce( + (total, design) => total + design.warnings.length, + 0, + ) + } + + syncDerived() + + let autosaveTimer: ReturnType | undefined + + function touch(): void { + book.value.updatedAt = new Date().toISOString() + } + + watch( + book, + () => { + syncDerived() + + if (autosaveTimer !== undefined) { + clearTimeout(autosaveTimer) + } + + autosaveTimer = setTimeout(() => { + saveStatus.value = 'saving' + const result = saveBook(book.value) + if (result.ok) { + saveStatus.value = 'saved' + lastError.value = null + } else { + saveStatus.value = 'error' + lastError.value = result.message + } + }, AUTOSAVE_DELAY_MS) + }, + { deep: true }, + ) + + function detectDuplicates(files: readonly File[]): string[] { + const existingNames = new Set(book.value.designs.map((design) => design.originalFilename)) + return files.map((file) => file.name).filter((name) => existingNames.has(name)) + } + + async function importFiles( + files: readonly File[], + strategy: DuplicateStrategy, + ): Promise { + const markdownFiles = files.filter((file) => /\.md$/i.test(file.name)) + const failed: ImportResult['failed'] = files + .filter((file) => !/\.md$/i.test(file.name)) + .map((file) => ({ filename: file.name, message: '仅支持 .md 文件。' })) + + const sortedFiles = sortFilesNaturally([...markdownFiles]) + const duplicates: string[] = [] + let imported = 0 + + for (const file of sortedFiles) { + try { + const text = await file.text() + const design = parseTeachingDesign(file.name, text) + + const existingIndex = book.value.designs.findIndex( + (existing) => existing.originalFilename === file.name, + ) + + if (existingIndex !== -1) { + duplicates.push(file.name) + if (strategy === 'replace') { + book.value.designs.splice(existingIndex, 1, design) + } else { + book.value.designs.push(design) + } + } else { + book.value.designs.push(design) + } + + imported++ + } catch (error) { + failed.push({ + filename: file.name, + message: error instanceof Error ? error.message : '解析失败。', + }) + } + } + + if (imported > 0 && book.value.selectedId === 'cover' && book.value.designs.length > 0) { + book.value.selectedId = book.value.designs[0]!.id + } + + if (imported > 0) { + touch() + } + + return { imported, failed, duplicates } + } + + function selectPage(id: 'cover' | DesignId): void { + book.value.selectedId = id + } + + function moveDesign(from: number, to: number): void { + const designs = book.value.designs + if (from < 0 || from >= designs.length || to < 0 || to >= designs.length) { + return + } + const [moved] = designs.splice(from, 1) + designs.splice(to, 0, moved!) + touch() + } + + function removeDesign(id: DesignId): void { + const designs = book.value.designs + const index = designs.findIndex((design) => design.id === id) + if (index === -1) { + return + } + designs.splice(index, 1) + + if (book.value.selectedId === id) { + book.value.selectedId = designs[index]?.id ?? designs[index - 1]?.id ?? 'cover' + } + + touch() + } + + function updateCover(patch: Partial ): void { + Object.assign(book.value.cover, patch) + touch() + } + + function updateDesign(id: DesignId, updater: (design: TeachingDesign) => void): void { + const design = book.value.designs.find((candidate) => candidate.id === id) + if (!design) { + return + } + updater(design) + touch() + } + + function restore(restored: TeachingBook): void { + book.value = restored + } + + function clearBook(): void { + book.value = createEmptyBook() + } + + return { + book, + saveStatus, + lastError, + pendingDuplicateFiles, + selectedDesign, + hasDesigns, + warningCount, + importFiles, + detectDuplicates, + selectPage, + moveDesign, + removeDesign, + updateCover, + updateDesign, + restore, + clearBook, + } +} diff --git a/src/main.ts b/src/main.ts index 2425c0f..21c3125 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { createApp } from 'vue' import './style.css' +import './print.css' import App from './App.vue' createApp(App).mount('#app') diff --git a/src/print.css b/src/print.css new file mode 100644 index 0000000..0ecc906 --- /dev/null +++ b/src/print.css @@ -0,0 +1,85 @@ +@page { + size: A4; + margin: 12mm; +} + +.print-book { + display: none; +} + +@media print { + html, + body, + #app { + margin: 0; + padding: 0; + background: #fff; + } + + .app-shell > *:not(.print-book) { + display: none !important; + } + + .print-book { + display: block; + } + + .print-section { + break-before: page; + } + + .print-section:first-child { + break-before: auto; + } + + .page { + width: auto; + min-height: 0; + margin: 0; + padding: 0; + box-shadow: none; + } + + .process-table { + break-inside: auto; + } + + .process-table thead { + display: table-header-group; + } + + .process-table tr { + break-inside: avoid; + } + + .section-heading { + break-after: avoid; + } + + .basic-info-table, + .reflection-table, + .board-design { + break-inside: avoid; + } + + .no-print, + .warning-summary { + display: none !important; + } + + .markdown-source { + display: none; + } + + .editable-text--static, + .markdown-preview { + border-color: transparent; + background: none; + white-space: pre-wrap; + word-break: break-word; + } + + .board-design { + word-break: break-word; + } +} diff --git a/src/services/bookStorage.test.ts b/src/services/bookStorage.test.ts new file mode 100644 index 0000000..2b3e127 --- /dev/null +++ b/src/services/bookStorage.test.ts @@ -0,0 +1,26 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createEmptyBook } from '../domain/teachingDesign' +import { clearStoredBook, loadStoredBook, saveBook } from './bookStorage' + +describe('bookStorage', () => { + beforeEach(() => localStorage.clear()) + + it('round-trips a versioned book', () => { + const book = createEmptyBook() + book.cover.courseName = 'Web 前端开发' + + expect(saveBook(book)).toEqual({ ok: true }) + expect(loadStoredBook()?.cover.courseName).toBe('Web 前端开发') + }) + + it('returns null for malformed storage', () => { + localStorage.setItem('teaching-design-book', '{bad json') + expect(loadStoredBook()).toBeNull() + }) + + it('clears saved work', () => { + saveBook(createEmptyBook()) + clearStoredBook() + expect(loadStoredBook()).toBeNull() + }) +}) diff --git a/src/services/bookStorage.ts b/src/services/bookStorage.ts new file mode 100644 index 0000000..fbc6707 --- /dev/null +++ b/src/services/bookStorage.ts @@ -0,0 +1,29 @@ +import { BOOK_SCHEMA_VERSION, type TeachingBook } from '../domain/teachingDesign' + +const STORAGE_KEY = 'teaching-design-book' + +export type SaveResult = { ok: true } | { ok: false; message: string } + +export function saveBook(book: TeachingBook): SaveResult { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(book)) + return { ok: true } + } catch { + return { ok: false, message: '浏览器存储空间不足,当前修改尚未暂存。' } + } +} + +export function loadStoredBook(): TeachingBook | null { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as TeachingBook + return parsed.schemaVersion === BOOK_SCHEMA_VERSION ? parsed : null + } catch { + return null + } +} + +export function clearStoredBook(): void { + localStorage.removeItem(STORAGE_KEY) +} diff --git a/src/services/markdownParser.corpus.test.ts b/src/services/markdownParser.corpus.test.ts new file mode 100644 index 0000000..da5cf56 --- /dev/null +++ b/src/services/markdownParser.corpus.test.ts @@ -0,0 +1,35 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parseTeachingDesign } from './markdownParser' + +const fixture = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8') + +describe('teaching-design corpus', () => { + it.each([ + ['data/Web/1.md', '个人主页——项目启动与开发环境搭建'], + ['data/Python/1.md', '智能学生选课推荐系统——项目启动与Python开发环境搭建'], + ['data/C#/8.md', '智能仓储管理系统——异常处理与调试确保系统稳定运行'], + ['data/C#/19.md', '智能教室环境监测系统——数据可视化与历史曲线绘制'], + ])('parses %s without losing its topic', (path, topic) => { + const design = parseTeachingDesign(path.split('/').at(-1) ?? path, fixture(path)) + expect(design.topic).toBe(topic) + expect(design.processSteps.length).toBeGreaterThan(0) + }) + + it('imports every numbered corpus file without throwing', () => { + const directories = ['data/Web', 'data/Python', 'data/C#'] + const paths = directories.flatMap((directory) => + readdirSync(resolve(process.cwd(), directory)) + .filter((name) => /^\d+\.md$/.test(name)) + .map((name) => `${directory}/${name}`), + ) + + expect(paths).toHaveLength(55) + for (const path of paths) { + const design = parseTeachingDesign(path.split('/').at(-1) ?? path, fixture(path)) + expect(design.topic || design.title).not.toBe('') + expect(design.originalFilename).toMatch(/\.md$/) + } + }) +}) diff --git a/src/services/markdownParser.test.ts b/src/services/markdownParser.test.ts new file mode 100644 index 0000000..b4dc70c --- /dev/null +++ b/src/services/markdownParser.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { parseTeachingDesign } from './markdownParser' + +const standard = `# 个人主页——项目启动 教学设计 + +| **课题** | **个人主页——项目启动** | +|:---|:---| +| **课时** | 1课时(40分钟) | +| **教学目标** | **知识目标**:认识 HTML。
**技能目标**:创建页面。
**素养目标**:规范操作。 | +| **教学重难点** | **重点**:HTML。
**难点**:路径。 | +| **教学资源准备** | 浏览器。 | + +## 教学过程 + +| 教学环节 | 教学内容 | 教师活动 | 学生活动 | 设计意图 | +|:---|:---|:---|:---|:---| +| **1. 导入**
(6分钟) | 展示案例。 | **情境创设**
提问。 | **观察思考**
回答。 | 建立目标。 | + +## 板书设计 + +\`\`\`text +HTML → 浏览器 +\`\`\` + +## 教学成效与反思 + +| | | +|:---|:---| +| **教学成效** | 完成页面。 | +| **教学反思** | 加强路径讲解。 | +` + +describe('parseTeachingDesign', () => { + it('parses the complete teaching-design structure', () => { + const design = parseTeachingDesign('1.md', standard) + + expect(design.topic).toBe('个人主页——项目启动') + expect(design.knowledgeObjective).toBe('认识 HTML。') + expect(design.processSteps[0]).toMatchObject({ + name: '1. 导入', + duration: '6分钟', + content: '展示案例。', + }) + expect(design.boardDesign).toContain('HTML → 浏览器') + expect(design.reflection).toBe('加强路径讲解。') + expect(design.warnings).toEqual([]) + }) + + it('accepts half-width punctuation and reports missing sections', () => { + const markdown = standard + .replaceAll(':', ':') + .replace(/## 板书设计[\s\S]*?(?=## 教学成效与反思)/, '') + + const design = parseTeachingDesign('8.md', markdown) + + expect(design.knowledgeObjective).toBe('认识 HTML。') + expect(design.boardDesign).toBe('') + expect(design.warnings.some((warning) => warning.code === 'missing-board')).toBe(true) + }) + + it('parses process steps where the step number is outside the bold name', () => { + const markdown = standard.replace( + '| **1. 导入**
(6分钟) | 展示案例。 | **情境创设**
提问。 | **观察思考**
回答。 | 建立目标。 |', + '| 1. **导入**
(6分钟) | 展示案例。 | **情境创设**
提问。 | **观察思考**
回答。 | 建立目标。 |', + ) + + const design = parseTeachingDesign('1.md', markdown) + + expect(design.processSteps[0]).toMatchObject({ + name: '1. 导入', + duration: '6分钟', + }) + }) + + it('reports a missing title when no level-one heading exists', () => { + const markdown = standard.replace('# 个人主页——项目启动 教学设计\n\n', '') + + const design = parseTeachingDesign('1.md', markdown) + + expect(design.warnings.some((warning) => warning.code === 'missing-title')).toBe(true) + }) +}) diff --git a/src/services/markdownParser.ts b/src/services/markdownParser.ts new file mode 100644 index 0000000..c12e1f1 --- /dev/null +++ b/src/services/markdownParser.ts @@ -0,0 +1,282 @@ +import { + createEmptyTeachingDesign, + createTeachingStep, + type ParseWarning, + type TeachingDesign, + type TeachingStep, +} from '../domain/teachingDesign' +import { extractMarkdownTable } from './markdownTable' + +const BR = /
/gi +const LABEL_MARKS = /[*_`]/g +const COLON = /[::]\s*$/ +const PAREN_DURATION = /[((]([^()()]*)[))]/ + +const KNOWN_SECTION_HEADINGS = new Set(['教学过程', '板书设计', '教学成效与反思']) + +function cleanLabel(value: string): string { + return value.replace(LABEL_MARKS, '').trim() +} + +function stripOuterBold(value: string): string { + return value.trim().replace(/^\*\*([\s\S]*)\*\*$/, '$1').trim() +} + +function normalizeMultiline(value: string): string { + return value.replace(BR, '\n').trim() +} + +function isSectionHeading(line: string, heading: string): boolean { + const trimmed = line.trim() + return ( + new RegExp(`^##\\s+${heading}\\s*$`).test(trimmed) || trimmed === `**${heading}**` + ) +} + +function findSectionIndex(lines: readonly string[], heading: string, fromIndex = 0): number { + for (let index = fromIndex; index < lines.length; index += 1) { + if (isSectionHeading(lines[index] ?? '', heading)) return index + } + return -1 +} + +function isAnyHeading(line: string): boolean { + const trimmed = line.trim() + return /^##\s+\S/.test(trimmed) || /^\*\*[^*]+\*\*$/.test(trimmed) +} + +function findNextHeadingIndex(lines: readonly string[], fromIndex: number): number { + for (let index = fromIndex; index < lines.length; index += 1) { + if (isAnyHeading(lines[index] ?? '')) return index + } + return -1 +} + +function headingName(line: string): string { + const trimmed = line.trim() + const levelTwo = trimmed.match(/^##\s+(.+)$/) + if (levelTwo) return levelTwo[1]!.trim() + return trimmed.slice(2, -2).trim() +} + +function splitLabelledValue(value: string, labels: readonly string[]): Record{ + const normalized = value.replace(BR, '\n') + const alternation = labels.join('|') + const pattern = new RegExp(`(?:\\*\\*(?:${alternation})\\*\\*|(?:${alternation}))\\s*[::]`, 'g') + const matches = [...normalized.matchAll(pattern)] + const result: Record = {} + + matches.forEach((match, index) => { + const label = cleanLabel(match[0].replace(COLON, '')) + const start = match.index + match[0].length + const end = index + 1 < matches.length ? matches[index + 1]!.index : normalized.length + result[label] = normalized.slice(start, end).trim() + }) + + return result +} + +function parseStepNameCell(cell: string, fallbackIndex: number): { name: string; duration: string } { + const normalized = cell.replace(BR, '\n') + const parts = normalized + .split('\n') + .map((part) => part.trim()) + .filter(Boolean) + + let namePart = parts[0] ?? '' + let durationPart = parts[1] ?? '' + let duration = '' + + const durationMatch = (durationPart || namePart).match(PAREN_DURATION) + if (durationMatch) { + duration = durationMatch[1]!.trim() + if (durationPart) { + durationPart = '' + } else { + namePart = namePart.replace(durationMatch[0], '').trim() + } + } + + const name = cleanLabel(namePart) || createTeachingStep(fallbackIndex).name + return { name, duration } +} + +function extractBoardContent(sectionLines: readonly string[]): string { + const fenceStart = sectionLines.findIndex((line) => /^\s*(`{3,}|~{3,})/.test(line)) + if (fenceStart < 0) { + return sectionLines.join('\n').trim() + } + + const fenceMatch = sectionLines[fenceStart]!.match(/^\s*(`{3,}|~{3,})/)! + const fenceChar = fenceMatch[1]![0]! + const fenceLength = fenceMatch[1]!.length + let fenceEnd = sectionLines.length + + for (let index = fenceStart + 1; index < sectionLines.length; index += 1) { + const close = sectionLines[index]!.match(/^\s*(`+|~+)\s*$/) + if (close && close[1]![0] === fenceChar && close[1]!.length >= fenceLength) { + fenceEnd = index + break + } + } + + return sectionLines.slice(fenceStart + 1, fenceEnd).join('\n').trim() +} + +export function parseTeachingDesign(filename: string, markdown: string): TeachingDesign { + const design = createEmptyTeachingDesign(filename) + const warnings: ParseWarning[] = [] + const lines = markdown.replace(/\r\n/g, '\n').split('\n') + + const titleLineIndex = lines.findIndex((line) => /^#\s+\S/.test(line.trim())) + let headingTitle = '' + if (titleLineIndex >= 0) { + headingTitle = lines[titleLineIndex]!.trim().replace(/^#\s+/, '').trim() + } else { + warnings.push({ code: 'missing-title', message: '未找到课程标题(一级标题)。' }) + } + + const basicTable = extractMarkdownTable(lines, titleLineIndex + 1) + const basicFieldsFound = new Set () + + if (basicTable) { + for (const row of [basicTable.header, ...basicTable.rows]) { + const label = cleanLabel(row[0] ?? '') + const value = (row[1] ?? '').trim() + + switch (label) { + case '课题': + design.topic = stripOuterBold(value) + basicFieldsFound.add('topic') + break + case '课时': + design.duration = value + basicFieldsFound.add('duration') + break + case '教学目标': { + const objectives = splitLabelledValue(value, ['知识目标', '技能目标', '素养目标']) + design.knowledgeObjective = objectives['知识目标'] ?? '' + design.skillObjective = objectives['技能目标'] ?? '' + design.literacyObjective = objectives['素养目标'] ?? '' + basicFieldsFound.add('objectives') + break + } + case '教学重难点': { + const points = splitLabelledValue(value, ['重点', '难点']) + design.keyPoint = points['重点'] ?? '' + design.difficultPoint = points['难点'] ?? '' + basicFieldsFound.add('points') + break + } + case '教学资源准备': + design.resources = value + basicFieldsFound.add('resources') + break + default: + break + } + } + } + + if (!basicTable) { + warnings.push({ code: 'missing-basic-field', message: '未找到基本信息表格。' }) + } else { + const requiredFields: Array<[string, string]> = [ + ['topic', '课题'], + ['duration', '课时'], + ['objectives', '教学目标'], + ['points', '教学重难点'], + ['resources', '教学资源准备'], + ] + for (const [key, label] of requiredFields) { + if (!basicFieldsFound.has(key)) { + warnings.push({ code: 'missing-basic-field', message: `缺少"${label}"信息。` }) + } + } + } + + const titleWithoutSuffix = headingTitle.replace(/\s*教学设计\s*$/, '').trim() + design.title = titleWithoutSuffix && titleWithoutSuffix !== design.topic ? headingTitle : '' + + const processIndex = findSectionIndex(lines, '教学过程', titleLineIndex + 1) + if (processIndex < 0) { + warnings.push({ code: 'missing-process', message: '未找到教学过程章节。' }) + } else { + const processTable = extractMarkdownTable(lines, processIndex + 1) + if (!processTable || processTable.header.length < 5) { + warnings.push({ code: 'invalid-process-table', message: '教学过程表格格式不正确。' }) + } else { + const steps: TeachingStep[] = [] + processTable.rows.forEach((row, index) => { + if (row.length < 5) return + const [nameCell, content, teacherActivity, studentActivity, intention] = row + const { name, duration } = parseStepNameCell(nameCell ?? '', index + 1) + steps.push({ + id: crypto.randomUUID(), + name, + duration, + content: normalizeMultiline(content ?? ''), + teacherActivity: normalizeMultiline(teacherActivity ?? ''), + studentActivity: normalizeMultiline(studentActivity ?? ''), + intention: normalizeMultiline(intention ?? ''), + }) + }) + + if (steps.length > 0) { + design.processSteps = steps + } else { + warnings.push({ code: 'invalid-process-table', message: '教学过程表格中没有有效的环节行。' }) + } + } + } + + const boardIndex = findSectionIndex(lines, '板书设计', titleLineIndex + 1) + if (boardIndex < 0) { + warnings.push({ code: 'missing-board', message: '未找到板书设计章节。' }) + } else { + const nextHeadingIndex = findNextHeadingIndex(lines, boardIndex + 1) + const sectionEnd = nextHeadingIndex < 0 ? lines.length : nextHeadingIndex + design.boardDesign = extractBoardContent(lines.slice(boardIndex + 1, sectionEnd)) + } + + const reflectionIndex = findSectionIndex(lines, '教学成效与反思', titleLineIndex + 1) + if (reflectionIndex < 0) { + warnings.push({ code: 'missing-reflection', message: '未找到教学成效与反思章节。' }) + } else { + const reflectionTable = extractMarkdownTable(lines, reflectionIndex + 1) + if (!reflectionTable) { + warnings.push({ code: 'missing-reflection', message: '教学成效与反思表格格式不正确。' }) + } else { + for (const row of reflectionTable.rows) { + const label = cleanLabel(row[0] ?? '') + const value = normalizeMultiline(row[1] ?? '') + if (label === '教学成效') design.effectiveness = value + if (label === '教学反思') design.reflection = value + } + } + } + + const additionalParts: string[] = [] + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]! + if (!isAnyHeading(line)) continue + const name = headingName(line) + if (KNOWN_SECTION_HEADINGS.has(name)) continue + + const nextHeadingIndex = findNextHeadingIndex(lines, index + 1) + const sectionEnd = nextHeadingIndex < 0 ? lines.length : nextHeadingIndex + const content = lines.slice(index + 1, sectionEnd).join('\n').trim() + + if (content) { + additionalParts.push(`## ${name}\n\n${content}`) + } + } + + if (additionalParts.length > 0) { + design.additionalContent = additionalParts.join('\n\n') + warnings.push({ code: 'unclassified-content', message: '存在未识别的章节内容。' }) + } + + design.warnings = warnings + return design +} diff --git a/src/services/markdownRenderer.ts b/src/services/markdownRenderer.ts new file mode 100644 index 0000000..5b4a94b --- /dev/null +++ b/src/services/markdownRenderer.ts @@ -0,0 +1,12 @@ +import MarkdownIt from 'markdown-it' + +const renderer = new MarkdownIt({ + html: false, + breaks: true, + linkify: false, + typographer: false, +}) + +export function renderMarkdown(value: string): string { + return renderer.render(value || '') +} diff --git a/src/services/markdownTable.ts b/src/services/markdownTable.ts index 9bb096f..a835d67 100644 --- a/src/services/markdownTable.ts +++ b/src/services/markdownTable.ts @@ -101,23 +101,65 @@ export function splitMarkdownRow(row: string): string[] { const dividerCellPattern = /^:?-{3,}:?$/ -function startsWithPipe(line: string): boolean { +const FENCE_OPEN_PATTERN = / {0,3}(`{3,}|~{3,})/ +const FENCE_CLOSE_PATTERN = / {0,3}(`+|~+)\s*$/ + +function isTableRow(line: string): boolean { + const leading = line.match(/^[ \t]*/)?.[0] ?? '' + if (leading.includes('\t') || leading.length >= 4) { + return false + } return line.trimStart().startsWith('|') } +function computeFenceMask(lines: readonly string[]): boolean[] { + const mask = new Array (lines.length).fill(false) + let fenceChar: string | null = null + let fenceLength = 0 + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]! + + if (fenceChar === null) { + const open = line.match(new RegExp(`^${FENCE_OPEN_PATTERN.source}`)) + if (open) { + mask[index] = true + fenceChar = open[1]![0]! + fenceLength = open[1]!.length + } + continue + } + + mask[index] = true + const close = line.match(new RegExp(`^${FENCE_CLOSE_PATTERN.source}`)) + if (close && close[1]![0] === fenceChar && close[1]!.length >= fenceLength) { + fenceChar = null + fenceLength = 0 + } + } + + return mask +} + export function extractMarkdownTable( lines: readonly string[], fromIndex = 0, ): MarkdownTable | null { + const insideFence = computeFenceMask(lines) + for ( let start = Math.max(0, fromIndex); start < lines.length - 1; start++ ) { + if (insideFence[start] || insideFence[start + 1]) { + continue + } + const headerLine = lines[start]! const dividerLine = lines[start + 1]! - if (!startsWithPipe(headerLine) || !startsWithPipe(dividerLine)) { + if (!isTableRow(headerLine) || !isTableRow(dividerLine)) { continue } @@ -135,7 +177,11 @@ export function extractMarkdownTable( const rows: string[][] = [] let end = start + 1 - while (end + 1 < lines.length && startsWithPipe(lines[end + 1]!)) { + while ( + end + 1 < lines.length && + !insideFence[end + 1] && + isTableRow(lines[end + 1]!) + ) { end++ rows.push(splitMarkdownRow(lines[end]!)) } diff --git a/src/services/markdownWriter.test.ts b/src/services/markdownWriter.test.ts new file mode 100644 index 0000000..c1942f8 --- /dev/null +++ b/src/services/markdownWriter.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parseTeachingDesign } from './markdownParser' +import { writeTeachingDesignMarkdown } from './markdownWriter' + +describe('writeTeachingDesignMarkdown', () => { + it('writes canonical sections that can be parsed again', () => { + const source = readFileSync(resolve(process.cwd(), 'data/Web/1.md'), 'utf8') + const parsed = parseTeachingDesign('1.md', source) + const output = writeTeachingDesignMarkdown(parsed) + const reparsed = parseTeachingDesign('1.md', output) + + expect(output).toContain('## 板书设计') + expect(reparsed.topic).toBe(parsed.topic) + expect(reparsed.processSteps).toHaveLength(parsed.processSteps.length) + expect(reparsed.reflection).toBe(parsed.reflection) + }) + + it('escapes table-breaking pipes but preserves inline markdown', () => { + const source = parseTeachingDesign('1.md', readFileSync( + resolve(process.cwd(), 'data/Web/1.md'), + 'utf8', + )) + source.resources = '终端 | 浏览器与 `index.html`' + + expect(writeTeachingDesignMarkdown(source)).toContain( + '终端 \\| 浏览器与 `index.html`', + ) + }) + + it('omits decorative parentheses for steps without a duration', () => { + const source = parseTeachingDesign('1.md', readFileSync( + resolve(process.cwd(), 'data/Web/1.md'), + 'utf8', + )) + source.processSteps[0]!.duration = '' + source.processSteps[0]!.name = '1. 导入' + + const output = writeTeachingDesignMarkdown(source) + + expect(output).toContain('**1. 导入**') + expect(output).not.toContain('**1. 导入**
()') + expect(output).not.toContain('**1. 导入**()') + }) +}) diff --git a/src/services/markdownWriter.ts b/src/services/markdownWriter.ts new file mode 100644 index 0000000..d33b52b --- /dev/null +++ b/src/services/markdownWriter.ts @@ -0,0 +1,76 @@ +import type { TeachingDesign } from '../domain/teachingDesign' + +function escapeCell(value: string): string { + return value + .replace(/\r?\n/g, '
') + .replace(/(? + [ + escapeCell(processNameCell(step)), + escapeCell(step.content), + escapeCell(step.teacherActivity), + escapeCell(step.studentActivity), + escapeCell(step.intention), + ] + .map((cell) => `| ${cell}`) + .join(' ') + ' |', + ) + + const sections = [ + `# ${title}`, + '', + `| **课题** | **${escapeCell(design.topic)}** |`, + '|:---|:---|', + `| **课时** | ${escapeCell(design.duration)} |`, + `| **教学目标** | ${escapeCell(objectiveCell(design))} |`, + `| **教学重难点** | ${escapeCell(keyPointCell(design))} |`, + `| **教学资源准备** | ${escapeCell(design.resources)} |`, + '', + '## 教学过程', + '', + '| 教学环节 | 教学内容 | 教师活动 | 学生活动 | 设计意图 |', + '|:---|:---|:---|:---|:---|', + ...processRows, + '', + '## 板书设计', + '', + '```text', + design.boardDesign.trim(), + '```', + '', + '## 教学成效与反思', + '', + '| | |', + '|:---|:---|', + `| **教学成效** | ${escapeCell(design.effectiveness)} |`, + `| **教学反思** | ${escapeCell(design.reflection)} |`, + ] + + if (design.additionalContent.trim()) { + sections.push('', '## 附加内容', '', design.additionalContent.trim()) + } + + return `${sections.join('\n')}\n` +} diff --git a/src/services/zipExporter.test.ts b/src/services/zipExporter.test.ts new file mode 100644 index 0000000..6a776fa --- /dev/null +++ b/src/services/zipExporter.test.ts @@ -0,0 +1,35 @@ +import JSZip from 'jszip' +import { describe, expect, it } from 'vitest' +import { createEmptyTeachingDesign } from '../domain/teachingDesign' +import { createBookZip } from './zipExporter' + +describe('createBookZip', () => { + it('keeps original lesson filenames and adds an order manifest', async () => { + const second = createEmptyTeachingDesign('2.md') + second.topic = '第二课' + const first = createEmptyTeachingDesign('1.md') + first.topic = '第一课' + + const blob = await createBookZip([second, first]) + const zip = await JSZip.loadAsync(blob) + + expect(Object.keys(zip.files)).toEqual( + expect.arrayContaining(['2.md', '1.md', '课程顺序.txt']), + ) + await expect(zip.file('课程顺序.txt')?.async('text')).resolves.toContain('1. 2.md') + }) + + it('disambiguates duplicate filenames', async () => { + const first = createEmptyTeachingDesign('1.md') + first.topic = '第一课甲' + const duplicate = createEmptyTeachingDesign('1.md') + duplicate.topic = '第一课乙' + + const blob = await createBookZip([first, duplicate]) + const zip = await JSZip.loadAsync(blob) + + expect(Object.keys(zip.files)).toEqual( + expect.arrayContaining(['1.md', '1-2.md', '课程顺序.txt']), + ) + }) +}) diff --git a/src/services/zipExporter.ts b/src/services/zipExporter.ts new file mode 100644 index 0000000..43d4426 --- /dev/null +++ b/src/services/zipExporter.ts @@ -0,0 +1,32 @@ +import JSZip from 'jszip' +import type { TeachingDesign } from '../domain/teachingDesign' +import { writeTeachingDesignMarkdown } from './markdownWriter' + +export async function createBookZip(designs: readonly TeachingDesign[]): Promise{ + const zip = new JSZip() + const usedNames = new Set () + const order: string[] = [] + + designs.forEach((design, index) => { + let filename = design.originalFilename || `${index + 1}.md` + if (usedNames.has(filename)) { + const stem = filename.replace(/\.md$/i, '') + filename = `${stem}-${index + 1}.md` + } + usedNames.add(filename) + order.push(`${index + 1}. ${filename} — ${design.topic}`) + zip.file(filename, writeTeachingDesignMarkdown(design)) + }) + + zip.file('课程顺序.txt', `${order.join('\n')}\n`) + return zip.generateAsync({ type: 'blob' }) +} + +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) +} diff --git a/src/style.css b/src/style.css index 527d4fb..68f9589 100644 --- a/src/style.css +++ b/src/style.css @@ -1,296 +1,594 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); + font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; + color: #202a33; + background: #edf0f2; font-synthesis: none; text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } + --green-700: #216447; + --green-600: #2d7a58; + --green-100: #dceee5; + --line: #cfd5da; + --muted: #68747f; + --paper-width: 210mm; + --paper-min-height: 297mm; } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } +* { + box-sizing: border-box; } body { margin: 0; + min-width: 320px; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); -} - -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } +button, +textarea, +input { + font: inherit; } #app { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; + min-height: 100vh; } -#center { +.app-shell { display: flex; flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } + min-height: 100vh; } -#next-steps { +/* Toolbar */ +.workspace-toolbar { display: flex; - border-top: 1px solid var(--border); + align-items: center; + gap: 16px; + height: 56px; + flex: 0 0 56px; + padding: 0 16px; + background: #fff; + border-bottom: 1px solid var(--line); +} + +.workspace-toolbar button { + border: 1px solid var(--line); + background: #fff; + border-radius: 6px; + padding: 6px 14px; + color: var(--green-700); + cursor: pointer; +} + +.workspace-toolbar button:hover:not(:disabled) { + background: var(--green-100); + border-color: var(--green-600); +} + +.workspace-toolbar button:disabled { + color: var(--muted); + border-color: var(--line); + cursor: not-allowed; + opacity: 0.6; +} + +.workspace-toolbar-count, +.workspace-toolbar-warning, +.workspace-toolbar-status { + font-size: 14px; + color: var(--muted); +} + +.workspace-toolbar-warning { + color: #b65c00; +} + +.workspace-toolbar-status--error { + color: #c0392b; +} + +.workspace-toolbar-status--saved { + color: var(--green-700); +} + +/* Layout */ +.workspace-layout { + display: flex; + flex: 1 1 auto; + min-height: 0; +} + +/* Sidebar */ +.lesson-sidebar { + width: 260px; + flex: 0 0 260px; + background: #fff; + border-right: 1px solid var(--line); + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.lesson-sidebar-cover { + border: none; + border-bottom: 1px solid var(--line); + background: none; text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } + padding: 12px 16px; + font-weight: 600; + color: var(--green-700); + cursor: pointer; } -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { +.lesson-sidebar-list { list-style: none; + margin: 0; padding: 0; +} + +.lesson-sidebar-item { display: flex; + align-items: stretch; + border-bottom: 1px solid var(--line); +} + +.lesson-sidebar-item--active { + background: var(--green-100); + box-shadow: inset 3px 0 0 var(--green-600); +} + +.lesson-sidebar-select { + flex: 1 1 auto; + display: flex; + align-items: center; gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } + border: none; + background: none; + text-align: left; + padding: 10px 12px; + cursor: pointer; + min-width: 0; } -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } +.lesson-sidebar-number { + flex: 0 0 auto; + color: var(--muted); + font-size: 13px; } -.ticks { - position: relative; +.lesson-sidebar-topic { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lesson-sidebar-badge { + flex: 0 0 auto; + background: #e67e22; + color: #fff; + border-radius: 999px; + font-size: 12px; + line-height: 1.6; + min-width: 1.6em; + text-align: center; + padding: 0 4px; +} + +.lesson-sidebar-remove { + flex: 0 0 auto; + border: none; + background: none; + color: var(--muted); + cursor: pointer; + padding: 0 12px; + font-size: 16px; +} + +.lesson-sidebar-remove:hover { + color: #c0392b; +} + +/* Paper canvas */ +.a4-workspace { + flex: 1 1 auto; + overflow: auto; + padding: 16mm; + display: flex; + justify-content: center; +} + +.a4-paper { + flex: 0 0 auto; +} + +/* A4 page */ +.page { + display: block; + width: var(--paper-width); + min-height: var(--paper-min-height); + margin: 0 auto; + padding: 16mm; + background: #fff; + box-shadow: 0 4px 18px rgba(32, 42, 51, 0.12); +} + +.print-book .page { + box-shadow: none; +} + +/* Cover page */ +.cover-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 24px; +} + +.cover-title { + font-size: 40px; + font-weight: 700; + letter-spacing: 0.2em; + color: var(--green-700); + margin: 0; +} + +.cover-field { + display: flex; + align-items: center; + gap: 12px; + font-size: 18px; +} + +.cover-field-label { + color: var(--muted); +} + +.cover-field-value { + min-width: 12em; + text-align: left; + border-bottom: 1px solid var(--line); +} + +/* Teaching design page */ +.teaching-design-page { + display: flex; + flex-direction: column; + gap: 12px; +} + +.design-title { + text-align: center; + font-size: 22px; + font-weight: 700; + color: var(--green-700); +} + +.section-heading { + margin: 12px 0 0; + padding-left: 10px; + border-left: 4px solid var(--green-600); + font-size: 16px; + color: var(--green-700); +} + +table { width: 100%; + border-collapse: collapse; + table-layout: fixed; +} - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; +.basic-info-table th, +.basic-info-table td, +.process-table th, +.process-table td, +.reflection-table th, +.reflection-table td { + border: 1px solid var(--line); + padding: 6px 8px; + vertical-align: top; + text-align: left; +} + +.basic-info-table th, +.process-table th, +.reflection-table th { + background: var(--green-100); + color: var(--green-700); + font-weight: 600; + width: 8em; +} + +.process-table th { + width: auto; +} + +.objectives-cell { + display: flex; + flex-direction: column; + gap: 4px; +} + +.objective-row { + display: flex; + align-items: baseline; + gap: 6px; +} + +.objective-label { + flex: 0 0 auto; + color: var(--muted); + font-size: 13px; +} + +.process-step-name { + display: flex; + flex-direction: column; + gap: 4px; +} + +.process-step-actions { + width: 6em; + text-align: center; +} + +.process-step-actions button { + border: 1px solid var(--line); + background: #fff; + border-radius: 4px; + padding: 2px 6px; + font-size: 12px; + cursor: pointer; + color: #c0392b; +} + +.process-step-actions button:disabled { + color: var(--muted); + cursor: not-allowed; +} + +.board-design { + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + white-space: pre-wrap; + border: 1px solid var(--line); + border-radius: 4px; + padding: 8px; + min-height: 6em; +} + +.warning-summary { + margin: 8px 0 0; + padding: 8px 12px; + border: 1px solid #e6c98b; + background: #fbf3e1; + border-radius: 4px; + color: #8a6116; + font-size: 13px; +} + +/* Editable fields */ +.editable-field { + font: inherit; + color: inherit; + line-height: 1.6; +} + +.editable-text { + display: block; + width: 100%; + border: 1px solid transparent; + border-radius: 4px; + padding: 2px 4px; + background: transparent; + resize: none; + overflow: hidden; +} + +.editable-text--multiline { + white-space: pre-wrap; +} + +.editable-text--static { + display: block; + width: 100%; + white-space: pre-wrap; + padding: 2px 4px; +} + +.editable-text:hover { + background: var(--green-100); +} + +.editable-text:focus { + background: #fff; + border-color: var(--green-600); + outline: none; +} + +.editable-markdown { + width: 100%; +} + +.markdown-preview { + min-height: 1.6em; + padding: 2px 4px; + border-radius: 4px; + border: 1px solid transparent; + cursor: text; +} + +.markdown-preview--empty { + color: var(--muted); +} + +.markdown-preview :first-child { + margin-top: 0; +} + +.markdown-preview :last-child { + margin-bottom: 0; +} + +.markdown-preview:hover { + background: var(--green-100); +} + +.markdown-source { + display: block; + width: 100%; + border: 1px solid var(--green-600); + border-radius: 4px; + padding: 2px 4px; + background: #fff; + resize: none; + overflow: hidden; + outline: none; +} + +/* Upload dropzone */ +.upload-dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + margin: 16mm auto; + max-width: 480px; + min-height: 200px; + border: 2px dashed var(--line); + border-radius: 12px; + background: #fff; + color: var(--muted); + text-align: center; + padding: 24px; + cursor: pointer; +} + +.upload-dropzone--drag-over { + border-color: var(--green-600); + background: var(--green-100); +} + +.upload-dropzone--compact { + flex-direction: row; + min-height: 0; + margin: 0; + padding: 6px 14px; + border-radius: 6px; +} + +.upload-dropzone-input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.upload-dropzone-title { + font-size: 16px; + color: #202a33; + margin: 0; +} + +.upload-dropzone-hint { + font-size: 13px; + margin: 0; +} + +/* Dialogs and notices */ +.dialog-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(32, 42, 51, 0.4); + z-index: 10; +} + +.dialog { + background: #fff; + border-radius: 8px; + padding: 24px; + max-width: 420px; + box-shadow: 0 12px 32px rgba(32, 42, 51, 0.25); +} + +.dialog-filenames { + margin: 8px 0; + padding-left: 20px; + color: var(--muted); +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +.dialog-actions button { + border: 1px solid var(--line); + background: #fff; + border-radius: 6px; + padding: 6px 14px; + cursor: pointer; +} + +.app-notice { + margin: 0; + padding: 8px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.app-notice--error { + background: #fdecea; + color: #c0392b; + border-bottom: 1px solid #f5c6c0; +} + +.app-notice button { + border: 1px solid currentcolor; + background: none; + border-radius: 4px; + padding: 2px 8px; + cursor: pointer; + color: inherit; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +/* Responsive */ +@media (max-width: 900px) { + .workspace-layout { + flex-direction: column; } - &::before { - left: 0; - border-left-color: var(--border); + .lesson-sidebar { + width: auto; + flex: 0 0 auto; + max-height: 180px; + border-right: none; + border-bottom: 1px solid var(--line); } - &::after { - right: 0; - border-right-color: var(--border); + + .a4-workspace { + padding: 6mm; + } + + .page { + width: 100%; + min-height: auto; } } diff --git a/tsconfig.app.json b/tsconfig.app.json index a40e9b2..4a5501c 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -2,7 +2,7 @@ "extends": "@vue/tsconfig/tsconfig.dom.json", "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "types": ["vite/client", "vitest/globals"], + "types": ["vite/client", "vitest/globals", "node"], /* Linting */ "noUnusedLocals": true,
Connect with us
-Join the Vite community
---
-
-
- GitHub
-
-
- -
-
-
- Discord
-
-
- -
-
-
- X.com
-
-
- -
-
-
- Bluesky
-
-
-
-