From 634042642252dc2a84e810d780a9aeb3d0ac2156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Mon, 27 Oct 2025 16:59:34 +0800 Subject: [PATCH 01/44] test --- backend/dishes/views.py | 46 +++ data/meal_architect.db | Bin 274432 -> 274432 bytes deploy/env.dev.template | 4 +- miniprogram/app.js | 11 +- miniprogram/app.json | 8 +- miniprogram/images/calendar-active.png | Bin 600 -> 356 bytes miniprogram/images/calendar.png | Bin 595 -> 306 bytes miniprogram/images/default-avatar.png | Bin 386 -> 1869 bytes miniprogram/images/default-dish.png | Bin 565 -> 4429 bytes miniprogram/images/dish-active.png | Bin 600 -> 0 bytes miniprogram/images/dish.png | Bin 595 -> 0 bytes miniprogram/images/home-active.png | Bin 0 -> 309 bytes miniprogram/images/home.png | Bin 0 -> 306 bytes miniprogram/images/logo-cover.png | Bin 0 -> 9479 bytes miniprogram/images/logo-small.png | Bin 0 -> 1191 bytes miniprogram/images/logo-text.png | Bin 0 -> 4946 bytes miniprogram/images/logo.png | Bin 985 -> 5318 bytes miniprogram/images/menu-active.png | Bin 0 -> 147 bytes miniprogram/images/menu.png | Bin 0 -> 144 bytes miniprogram/images/profile-active.png | Bin 600 -> 308 bytes miniprogram/images/profile.png | Bin 595 -> 304 bytes miniprogram/pages/chef/dish-edit/dish-edit.js | 66 ++-- miniprogram/pages/chef/dishes/dishes.js | 12 +- .../pages/gourmet/dish-detail/dish-detail.js | 106 ++++-- .../gourmet/dish-detail/dish-detail.wxml | 10 +- .../gourmet/dish-detail/dish-detail.wxss | 31 +- miniprogram/pages/home/home.js | 50 ++- miniprogram/pages/menu/menu.js | 12 +- .../pages/profile-edit/profile-edit.js | 1 + miniprogram/pages/profile/profile.js | 53 ++- miniprogram/utils/imageLoader.js | 84 ----- miniprogram/utils/imageManager.js | 317 ++++++++++++++++++ miniprogram/utils/request.js | 5 +- ...65\351\235\242\346\270\205\345\215\225.md" | 111 ++++++ ...42\347\273\223\346\236\204\345\233\276.md" | 259 ++++++++++++++ 35 files changed, 934 insertions(+), 252 deletions(-) delete mode 100644 miniprogram/images/dish-active.png delete mode 100644 miniprogram/images/dish.png create mode 100644 miniprogram/images/home-active.png create mode 100644 miniprogram/images/home.png create mode 100644 miniprogram/images/logo-cover.png create mode 100644 miniprogram/images/logo-small.png create mode 100644 miniprogram/images/logo-text.png create mode 100644 miniprogram/images/menu-active.png create mode 100644 miniprogram/images/menu.png delete mode 100644 miniprogram/utils/imageLoader.js create mode 100644 miniprogram/utils/imageManager.js create mode 100644 "\351\241\265\351\235\242\346\270\205\345\215\225.md" create mode 100644 "\351\241\265\351\235\242\347\273\223\346\236\204\345\233\276.md" diff --git a/backend/dishes/views.py b/backend/dishes/views.py index 236df9b..000d447 100644 --- a/backend/dishes/views.py +++ b/backend/dishes/views.py @@ -143,6 +143,52 @@ class DishViewSet(viewsets.ModelViewSet): status=status.HTTP_500_INTERNAL_SERVER_ERROR ) + @action(detail=True, methods=['delete']) + def delete_image(self, request, pk=None): + """删除菜品图片""" + if request.user.role != 'chef': + return Response( + {'error': '只有厨神可以删除图片'}, + status=status.HTTP_403_FORBIDDEN + ) + + dish = self.get_object() + + # 检查权限 + if dish.chef != request.user: + return Response( + {'error': '无权操作此菜品'}, + status=status.HTTP_403_FORBIDDEN + ) + + # 获取要删除的图片ID + image_id = request.data.get('image_id') + if not image_id: + return Response( + {'error': '请提供图片ID'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # 查找并删除图片 + dish_image = DishImage.objects.get(id=image_id, dish=dish) + dish_image.delete() + + logger.log_d(f"删除图片成功: image_id={image_id}, dish_id={pk}") + return Response(status=status.HTTP_204_NO_CONTENT) + + except DishImage.DoesNotExist: + return Response( + {'error': '图片不存在'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + logger.log_e(f"删除图片失败: {str(e)}") + return Response( + {'error': '删除图片失败'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + @action(detail=True, methods=['post']) def toggle_status(self, request, pk=None): """切换菜品状态(发布/草稿)""" diff --git a/data/meal_architect.db b/data/meal_architect.db index 7d89789c124244db90a8b46605baa0010bd953c1..3b91c0bee34e97dbf437aff0af927a3350bf0e6e 100644 GIT binary patch delta 1412 zcma)6U1(cn7(VADsm&z$PMFrENtzR9*+Bc{`#axH7>v+Ob|HEph&NKRB<-fI3rjmt zC^k$hQrHjQMUWNe>c4oA8EJ5}df|n5u?uf^RiQs-H}xl9FcAO|B2-d+EP1$x z`cN=|l+rWFr_y`AKIGKrOrZl2>xtoF?Tyl;{v$aM3}aYfiuGDD7ep~vh%h}c_7h_^#C?pwj-Kgy_1wdHDC9n2hR*S{3MONSNyPP~G!CLf5XqGOHO+#kDThEv z4`gHzMFev}^jJppPy$WuDChd~%#mJ22oy?6r2ov&peq+fkJ4g@g1!C_=p#<_n?nzI zQehmGUKt9|B9Jgae5{>pKp26T_(~}KT-FPog)lNn^i0qfL%|s_?fW@Rd>M47KoP5 zo_*bbWA&*72pzI}pVeC3xiFgVjOOj1TH!kuxYJI&x(d9lV0fWlUfZj5LrPwW2RHaIqL&Z>@CSyY%~y zSJy8tuiczmpIg|qjf(=!=A-hWn0+$c~D(y>&lpj$LfIi?i-G zvthR#Zf)lmTi((~p}tyQ+yb{i+z50)qH)BDTQRHu_|uO^`-{Vkqs`m-aQf};@)gs8 zjT_fC7v_zXPgTeNA(0m_HG_}LJa4@Jv9ZlBZ+4sCo|>Af74ms3%v5tIA;SfU5Xzr1 z<5tQ)*!6n8biOoI+Ix?YF{Ub9RZp6Wi4exy}=DZ&hgP9a`W7x3K)i~4M!SW~*S`svA zQWTAei9*g|(!>X;R$Uk}+JuQ9Zd@1^B)D~jN!!4xpxY*R7>&KRu)P2C|IYXSednAz ze=nH77hKLehp!aI2M&Ys$-6&HezqqN5z zOY?2<<;{uM3`7!U{w~s#6>EW&ktGWMKObwwoSJt^X(tj&2y?5HiY2t>1Q|C>feDR? z#^&`fO7;>8A)?LP7OxNt3-eoeu#!k1I9CMw@C~p7j=AtAT!rKCd)R?<@Cih;FAFO-KJ5JTikp8a*-L7~;ULRD;k0aSW-L^LF|{!6pL{7xO+> z55{?0`QILCp0>#NR_C|IbnXLsk33UC3?}7^ssHxlX_-IWm+cv|6Nh4p0GLqHmnkmV zKkH}IlY(VCX82p(tm0?5-kZJlZp@GF0Cxe!t@~3`LOCip?Be-%Zp&m)EO}dZqB#2u zQ^j`eFzc_UZ=Li%aKS_F!1DFAul75BNm=N=G3qf3!%@EOS4R2rJM>={F61+;Z!Nqp zo~*GcHhlk=p(xZ` zoOkp4-8cRYCHexlwwmsC>@EyTsb>;lh;Ha*O5olQ$dJQm#?=5cVQ<6I6ZVVZ6ppGm nqnfsu;RaIREn`@l1mX zlZO%-aiI7$L!ZuSS6ioCaqG(CLZ?@LO^#o)#eK_^TP{kf@8q_&hE0`F)(>O&8ND=Q z;*aYMD;uv_9Jjrr`p?Ybz2jB8<+G27eTq8z;FJAE_Qvn$UKR!)HvTA)qW}N7&0{Cy zjp2=H_Q{IILX&T7KF~1T!{reN6SJIBw`!k?fkU^;^CN;RY`b=Dyc2hDA`4qyvfjyK zIi2_P1*0Zdt1X%1e&!H^VD+>k7f=82$c)UcWKf8FloKhTandanzgIa{YN?^Ipu z!!p5z!IPz9n#J!Li4!x$r*<+-;!x;hh^-Obw&q)A+Vdla*WErE_ao!dOQ%IMYjee` zy)6?W-`>@nmJ)IfqI436fbPU7$=7q|s2hU~_fT?}bmZpqqj$_hPk{{gm}ZgfIekw_ zm>N@x=Nw&W@9B0E!V^ztRX^NyI{Vns9kXt_AGAE%X8q8os{*Pi6M6h;{x4ShAle<8n*3Z%Fz4RVHWdyc}69JgAk*NAPTXMk*PzJQ*!Sk RX<&+B@O1TaS?83{1OO#t_gVk| diff --git a/miniprogram/images/calendar.png b/miniprogram/images/calendar.png index f701655d01bb483aa0807fde6c4385a256e9c2ac..4263889e7447fd01c94280a637e24451db0759ca 100644 GIT binary patch literal 306 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=KRsO>Ln`LHz3#}@WFX>tG3n0S zb;>6B%eyIgDXS1vJ$XS8BC%k+Z5gnI$=1)T;yhs_LD3~PTfb-ib@ zeD3ZtK`8e6-TK;XKZOf)V-w!5eS6||LgzF82G$FV8Db9X3nCe08`2m{7-p3>o>2II oVz)!D@3}MR#(~V=@{GHJ@zTn$dtq#|K%v6m>FVdQ&MBb@0MqS&lmGw# literal 595 zcmeAS@N?(olHy`uVBq!ia0vp^fgsGm1|(PYdzmpXFiCm3IEGZrc{}HH;1L4>)^trK zMUGF6v-MgIyP7j8aViwm!Z1)Pheq*QSRrfn7z=L5LBJ zkaK4*P-~g+l|wXnPsQ;`r>q$FJK`-803?MM?FY*xuH%sWX)I!x?@?uPvGQ<9fr& z#%~tKb?>PDGqZT__|?4Ao+>So@WM9bExc%JA!r;TkA7!5C{C{rq*vYsstTD|# zS<*9&s@V%PGxPU8iE;*zNNCh$xHNvYi`a;tx(`QCpXsck)6WV0pu!~Ld1vbs;jOA`eOM;A zFnF?@n6~40&5RW@#iw>MOyW@JWC*Si6?6JwuzC8S!~2qY{A)~}`Y2vJW0%~sUX{H? z;NF}hO}D2!Ky_fnH-%PC-FS0lrBSdNlLyrJ+PF2f!An8LHzalLo>p?&J3Up&Vd{~) zn>LodWUd67ZXtC&cAmE0vFS7Sn$Nj>W`;^e*1;QzAG;)+FVERg6&dXL;MQ>?aLt@>ftOc{9|NT)dz@dUI=$|BWK?PO(MO+dTwi_uhumJh zW!v-_F1SfC+`7c-Fq@k(W43m~L!t8-hqu;uO=D+3i$oQ{2h!IJ;^jXw6ix*u76wmO KKbLh*2~7ay^z%{x diff --git a/miniprogram/images/default-avatar.png b/miniprogram/images/default-avatar.png index 8516f4594b7932d6385727efa95dd16807afa35d..5eac4eb5a64c0c739bf5a3a46a05bf6632b36137 100644 GIT binary patch literal 1869 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 Y7n1W7&I%1p1%@qyr>mdKI;Vst08b!-hX4Qo diff --git a/miniprogram/images/default-dish.png b/miniprogram/images/default-dish.png index 73b7d49dc20eff685380e53641ed39e37d307675..2de5304b27afd1f65fa6b787b31ac1a3665d09e9 100644 GIT binary patch literal 4429 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 literal 565 zcmeAS@N?(olHy`uVBq!ia0vp^CxCbw2NRGK*%#o)z`*#&)5S5QV$R!}2RjcrNHheN zZ)_Jyl-$V0y}2bsF{0JCLzhF&Ca>@JM1wu$oGJef%h;tU{M;G0p8ZGu{^pY q9{}!=kG~_PrQWVJkWd%vREn`@l1mX zlZO%-aiI7$L!ZuSS6ioCaqG(CLZ?@LO^#o)#eK_^TP{kf@8q_&hE0`F)(>O&8ND=Q z;*aYMD;uv_9Jjrr`p?Ybz2jB8<+G27eTq8z;FJAE_Qvn$UKR!)HvTA)qW}N7&0{Cy zjp2=H_Q{IILX&T7KF~1T!{reN6SJIBw`!k?fkU^;^CN;RY`b=Dyc2hDA`4qyvfjyK zIi2_P1*0Zdt1X%1e&!H^VD+>k7f=82$c)UcWKf8FloKhTandanzgIa{YN?^Ipu z!!p5z!IPz9n#J!Li4!x$r*<+-;!x;hh^-Obw&q)A+Vdla*WErE_ao!dOQ%IMYjee` zy)6?W-`>@nmJ)IfqI436fbPU7$=7q|s2hU~_fT?}bmZpqqj$_hPk{{gm}ZgfIekw_ zm>N@x=Nw&W@9B0E!V^ztRX^NyI{Vns9kXt_AGAE%X8q8os{*Pi6M6h;{x4ShAle<8n*3Z%Fz4RVHWdyc}69JgAk*NAPTXMk*PzJQ*!Sk RX<&+B@O1TaS?83{1OO#t_gVk| diff --git a/miniprogram/images/dish.png b/miniprogram/images/dish.png deleted file mode 100644 index f701655d01bb483aa0807fde6c4385a256e9c2ac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 595 zcmeAS@N?(olHy`uVBq!ia0vp^fgsGm1|(PYdzmpXFiCm3IEGZrc{}HH;1L4>)^trK zMUGF6v-MgIyP7j8aViwm!Z1)Pheq*QSRrfn7z=L5LBJ zkaK4*P-~g+l|wXnPsQ;`r>q$FJK`-803?MM?FY*xuH%sWX)I!x?@?uPvGQ<9fr& z#%~tKb?>PDGqZT__|?4Ao+>So@WM9bExc%JA!r;TkA7!5C{C{rq*vYsstTD|# zS<*9&s@V%PGxPU8iE;*zNNCh$xHNvYi`a;tx(`QCpXsck)6WV0pu!~Ld1vbs;jOA`eOM;A zFnF?@n6~40&5RW@#iw>MOyW@JWC*Si6?6JwuzC8S!~2qY{A)~}`Y2vJW0%~sUX{H? z;NF}hO}D2!Ky_fnH-%PC-FS0lrBSdNlLyrJ+PF2f!An8LHzalLo>p?&J3Up&Vd{~) zn>LodWUd67ZXtC&cAmE0vFS7Sn$Nj>W`;^e*1;QzAG;)+FVERg6&dXL;MQ>?aLt@>ftOc{9|NT)dz@dUI=$|BWK?PO(MO+dTwi_uhumJh zW!v-_F1SfC+`7c-Fq@k(W43m~L!t8-hqu;uO=D+3i$oQ{2h!IJ;^jXw6ix*u76wmO KKbLh*2~7ay^z%{x diff --git a/miniprogram/images/home-active.png b/miniprogram/images/home-active.png new file mode 100644 index 0000000000000000000000000000000000000000..cfe38f7ec960fa2d2b6c4283fb60e6548d5ce52b GIT binary patch literal 309 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=e>`0rLn`LHoqkcU$v}Yh;6{$M zLEaa&wsQaZq`ugv;f}6PS$*T~>81sR5-wn%GXHe)tP}r~ul~4x=>B&@TmP!Vv*K-n zU7{A7M%y~OR5XjduUOKt;gF#Gb`HU_+-Wseqq{P~IwaC{U2^ms&VNk2p_cTuxjLfa zq?BN{{Ef$lyr*@n*!PixTYsgXU=?46rtT}9f-a3J6%hya1%V8*4bNomJ}CWN|Mpi* zU3~Vg`;1%*m@jBF@Ht%G+wj3K!upVs`(2s4?-^JxFlNBCsQr-%F#5jzyZ5u13x3Nk sV6bB7WqQG20@Jye!K$I7gX1{+!YC`>6-$})f#Ja5>FVdQ&MBb@00a1W+5i9m literal 0 HcmV?d00001 diff --git a/miniprogram/images/home.png b/miniprogram/images/home.png new file mode 100644 index 0000000000000000000000000000000000000000..4702f99b6283690fcb8cb024c95b0f95aa443c29 GIT binary patch literal 306 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=KRsO>Ln`LHoqkZL$$*D-_2cb_ z%nzP_b<*2?CX=t&vfbedm9g9_mv(@Ghu!H-drto2U-fZ)>-o=f=Be*ZJgq-ZQ)%tR ziEHO6D(!LXFSiMDiFnx2AKlXNj@5YID_hqRZI_`xd4b3?^I)m@n*EB<*wg%lBKm*6n`( z`s@d52gVWxDb@>&8ExVMc5Kn^5l>d!PWmCcfWeBP7pA9WK6BQ#f8Xj(+6eRPXBKf_ pUl7P3+u#P%2^0_z6nyxDq0-KNXS?$5)xbbt@O1TaS?83{1OTToc?JLg literal 0 HcmV?d00001 diff --git a/miniprogram/images/logo-cover.png b/miniprogram/images/logo-cover.png new file mode 100644 index 0000000000000000000000000000000000000000..cdabfe06ab8fa4496bead7ce5876d9facb68c74c GIT binary patch literal 9479 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 R2b?rK&-(oG^xxsv{tE%OW+4Co literal 0 HcmV?d00001 diff --git a/miniprogram/images/logo-small.png b/miniprogram/images/logo-small.png new file mode 100644 index 0000000000000000000000000000000000000000..99204b55baca627119d708a7021e19abd18b6bb4 GIT binary patch literal 1191 zcmV;Y1X%ltP) zyK&qw5QZU-bGD)mG)@#=B}!b06IY_piRU_SD{R2$03HnI_*!5e{{q}EK2gN~VVAEv z;^pypqyWbk^bTB|5CA3wfC&L$LI9W$044;02?1b20GJQ}CIo=_ir#@5K3*$6{PT~} zUdZy|`es~J;>URP;ZlHL?rWTx@FYMr_ccCDI1pf*`x>YTr~r8MOF$Yx0$fGE#9RYH z04M$TiS)5wbNj!rbzm!?A$nrs$29n`(qJf{Dt(dM*HoD>abO~V9=%fTYv?AFHRuZ< zrf(Mg5~2o82g(8{=|iGlLeZe+KvTeJ`tazNoNCZ3JL=Hn)qvMOF{a9 zOz-I@!XAoV#@jv-^Phgka6d&`(81K`CHf~0+L2HrlB9nBn($6T2iy>f7`>5$>_D0uQ z1g&396l(q7e*B*SzrX(#*&y=2f4nwaok_Q#v8R7Vbc{^`uGxH+$Tgz)dODbT0WtkE zLPZc207gi_)uw+&V$_ua;>H7onv^6e;F{Av!ybDa1HjZ=347>2w>#-$pKr?haR&LS znEfkBgZ@JdeeX}SWxP+EZ^JwOnQBAs$J>FI#TMjafMx%=-Nn*3*z6v(#CQi`jsByZ zk;e8JIXG)UXX(r{soPG|VIsG?pLXO)z)t$a(D(jiTZS74Zmt8i(x2zKA${sbERP5K z&;*!Y0fT z{p79!GC3phTLsRd&$twjDS0tXxXiuaSU_Q!U@y;v$@Cy#Nk7RFVH|`x?`0d0)--4W ze7xhdKM1+`I}N=UAplGW0K*dy$Kebk2eN=T4%b3UELsp4wt(nDyaX!Wn6>#mi!1*? zBkc$vckF0EU?c=wxeKw!UMV2Xg(%ddB-DbgSp*WJj$;6rdI5X&b+sbc8*gQojtT(N zBp`}FX!Kx4EKBJ+4)b#2z5fcsPzE*d6Z3SdFZ=fUk;9PCk-e5-vNIu>JzKQwc z4UmA0D-ED0OHctBxCZD$JqH2`#wL6`O!Xw7plZTL?gf_u3W6q_Fd-Fd+a;2mliTz=QxWAplGW022bh`~wV-un+D-$xHwM002ovPDHLk FV1mK%9Gd_D literal 0 HcmV?d00001 diff --git a/miniprogram/images/logo-text.png b/miniprogram/images/logo-text.png new file mode 100644 index 0000000000000000000000000000000000000000..60799c50da706a60fcc08702813dae32b3a167f0 GIT binary patch literal 4946 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 literal 0 HcmV?d00001 diff --git a/miniprogram/images/logo.png b/miniprogram/images/logo.png index f6ea6c4486cc2c6dcac4ef30521aaeb0998b6eeb..854eee97843a4d5db2f4b2f65796095ca61dffd0 100644 GIT binary patch literal 5318 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 G>O?;~yvU zE}k0rEn%AI7Zqp8z|&=-g1J?UdpE>6d2e?Vapb=(_D*KD8cX*kLr=})lCyVPd~bWg z+v9EDe*67qk5s<@Pfi!EIs5X)?c2g%{xZF-W43Qqk}oSSzx~_pU-IwYzniS3+7>!W zNVNu#>ShsY`IeN^6gvQ$tPzli`1Jg>MAKAals_Fq~yzCqr(P1mvyFk zt$W>e_~FNoAAkO=3=Q3H#lFmTp?&cB>;8UzIk)5D;`-bcuX}y(&Yd~)=IQC^s7yZj zJvBW&eOl_;HEWI}8M+4QoZhu(&za|+qobp{7HzR(KN-Khuf!^LWr&%%`SD}NL|i#r zon|dtyKddG%P-s8*>}2K|5{_Wf8$0&OUsq3vJ?a)(;GHOHY@6&*0S2+1cr#vgq}$nw77AUAYpHpP&C&Dm1jbtZdox<fsEk2Je{5~&@S}VrYdSrgCcCgp&+qXZRJbJV> z_qOi2gP$+1UA%t1f9Tcybz8Vw4?TFWKw#18)!H(A;#{rubDEzozR>89ke8=7`DD$H zlKc1WMNOOPq4MOki&EmOw536rF^?xx^rG^E zzopr0PtEbM*si- literal 0 HcmV?d00001 diff --git a/miniprogram/images/menu.png b/miniprogram/images/menu.png new file mode 100644 index 0000000000000000000000000000000000000000..540ca7238398e01a06b2b403133ddc280a18118d GIT binary patch literal 144 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=;hrvzAr*7pUOmX$V8FwC;LxU- zD=#0~pWm(VD39Uvr{md-Kt&7(@?%~e*!z9*Ih)YXHQ(oQ%{s+!m$e}*&p^~1pjq>Q25p-V0_;rd;a6lC8wj5K%oAlTSwgc-*sQ!6yA2T?d`0!`XVTtc!hPJ zN+Zq5ovREn`@l1mX zlZO%-aiI7$L!ZuSS6ioCaqG(CLZ?@LO^#o)#eK_^TP{kf@8q_&hE0`F)(>O&8ND=Q z;*aYMD;uv_9Jjrr`p?Ybz2jB8<+G27eTq8z;FJAE_Qvn$UKR!)HvTA)qW}N7&0{Cy zjp2=H_Q{IILX&T7KF~1T!{reN6SJIBw`!k?fkU^;^CN;RY`b=Dyc2hDA`4qyvfjyK zIi2_P1*0Zdt1X%1e&!H^VD+>k7f=82$c)UcWKf8FloKhTandanzgIa{YN?^Ipu z!!p5z!IPz9n#J!Li4!x$r*<+-;!x;hh^-Obw&q)A+Vdla*WErE_ao!dOQ%IMYjee` zy)6?W-`>@nmJ)IfqI436fbPU7$=7q|s2hU~_fT?}bmZpqqj$_hPk{{gm}ZgfIekw_ zm>N@x=Nw&W@9B0E!V^ztRX^NyI{Vns9kXt_AGAE%X8q8os{*Pi6M6h;{x4ShAle<8n*3Z%Fz4RVHWdyc}69JgAk*NAPTXMk*PzJQ*!Sk RX<&+B@O1TaS?83{1OO#t_gVk| diff --git a/miniprogram/images/profile.png b/miniprogram/images/profile.png index f701655d01bb483aa0807fde6c4385a256e9c2ac..546d7d149485b92703fe67b34c0b285ba998ad39 100644 GIT binary patch literal 304 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=-#uL%Ln`LHo$1JT$UuOZdH&nk zzoK?1pK82e?Q|wad#&vC)m|^7lt7^VWUqkj`Ok%4-&pho`X?vN=c_&HB2n6y&-c&E zxT8AzL)6T}>d*Hqo|JRBU>EO4HWU8)GvZ?#;}xVFe!n#@Nn-SowOjj~OQp?c|KBC+ zco*ga;}zEuAM@y^_aEM6{4(`MRJG$zQJ}#R47m-*7?6zZ pWxT+a!P8*okj$`!QBZJ_`ur@P7=djjEx;gP@O1TaS?83{1OSGRbwU6D literal 595 zcmeAS@N?(olHy`uVBq!ia0vp^fgsGm1|(PYdzmpXFiCm3IEGZrc{}HH;1L4>)^trK zMUGF6v-MgIyP7j8aViwm!Z1)Pheq*QSRrfn7z=L5LBJ zkaK4*P-~g+l|wXnPsQ;`r>q$FJK`-803?MM?FY*xuH%sWX)I!x?@?uPvGQ<9fr& z#%~tKb?>PDGqZT__|?4Ao+>So@WM9bExc%JA!r;TkA7!5C{C{rq*vYsstTD|# zS<*9&s@V%PGxPU8iE;*zNNCh$xHNvYi`a;tx(`QCpXsck)6WV0pu!~Ld1vbs;jOA`eOM;A zFnF?@n6~40&5RW@#iw>MOyW@JWC*Si6?6JwuzC8S!~2qY{A)~}`Y2vJW0%~sUX{H? z;NF}hO}D2!Ky_fnH-%PC-FS0lrBSdNlLyrJ+PF2f!An8LHzalLo>p?&J3Up&Vd{~) zn>LodWUd67ZXtC&cAmE0vFS7Sn$Nj>W`;^e*1;QzAG;)+FVERg6&dXL;MQ>?aLt@>ftOc{9|NT)dz@dUI=$|BWK?PO(MO+dTwi_uhumJh zW!v-_F1SfC+`7c-Fq@k(W43m~L!t8-hqu;uO=D+3i$oQ{2h!IJ;^jXw6ix*u76wmO KKbLh*2~7ay^z%{x diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.js b/miniprogram/pages/chef/dish-edit/dish-edit.js index 479a924..37ffebd 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.js +++ b/miniprogram/pages/chef/dish-edit/dish-edit.js @@ -1,6 +1,7 @@ // pages/chef/dish-edit/dish-edit.js const { get, post, put } = require('../../../utils/request') const { showLoading, hideLoading, showSuccess, showError } = require('../../../utils/util') +const { imageManager } = require('../../../utils/imageManager') const app = getApp() Page({ @@ -50,25 +51,19 @@ Page({ .then(res => { hideLoading() - // 处理图片URL,确保是完整的绝对路径 - const images = (res.images || []).map((image, index) => { - if (image.image_url) { - // 如果图片URL不是完整的HTTP/HTTPS URL,则拼接baseUrl - if (!image.image_url.startsWith('http')) { - const app = getApp() - image.image_url = `${app.globalData.baseUrl}${image.image_url.startsWith('/') ? '' : '/'}${image.image_url}` - } - // 清理URL中的重试参数 - image.image_url = image.image_url.split('?')[0] - - // 预加载图片到本地 + // 使用统一的图片管理器处理图片 + const images = imageManager.processImages(res.images || []) + + // 预加载图片到本地 + images.forEach((image, index) => { + if (image.image_url && !image.image_url.startsWith('/images/')) { this.preloadImage(image.image_url, index) } - return image }) // 处理制作步骤(从API返回的结构转换为前端需要的结构) - const cookingSteps = (res.cooking_steps || []).map((step, index) => ({ + const processedSteps = imageManager.processCookingSteps(res.cooking_steps || []) + const cookingSteps = processedSteps.map((step, index) => ({ step_number: step.step_number || index + 1, description: step.description || '', image_url: step.image_url, @@ -363,36 +358,27 @@ Page({ const imageToDelete = images[index] // 如果是服务器上的图片,需要调用删除接口 - if (imageToDelete.id) { + if (imageToDelete.id && this.data.dishId) { showLoading('删除中...') - wx.request({ - url: `${app.globalData.baseUrl}/api/chef/dishes/${this.data.dishId}/delete_image/`, - method: 'DELETE', - data: { - image_id: imageToDelete.id - }, - header: { - 'Authorization': `Bearer ${app.globalData.token}`, - 'Content-Type': 'application/json' - }, - success: (res) => { + // 使用统一的request工具 + const { del } = require('../../../utils/request') + + del(`/api/chef/dishes/${this.data.dishId}/delete_image/`, { + image_id: imageToDelete.id + }) + .then(() => { hideLoading() - if (res.statusCode === 200) { - images.splice(index, 1) - this.setData({ - 'formData.images': images - }) - showSuccess('图片删除成功') - } else { - showError('删除失败') - } - }, - fail: () => { + images.splice(index, 1) + this.setData({ + 'formData.images': images + }) + showSuccess('图片删除成功') + }) + .catch((err) => { hideLoading() - showError('删除失败') - } - }) + showError(err.message || '删除失败') + }) } else { // 如果是本地临时图片,直接删除 images.splice(index, 1) diff --git a/miniprogram/pages/chef/dishes/dishes.js b/miniprogram/pages/chef/dishes/dishes.js index 570df9c..c5a11a7 100644 --- a/miniprogram/pages/chef/dishes/dishes.js +++ b/miniprogram/pages/chef/dishes/dishes.js @@ -1,6 +1,7 @@ // pages/chef/dishes/dishes.js const { get, del, put } = require('../../../utils/request') const { showLoading, hideLoading, showSuccess, showError, showConfirm } = require('../../../utils/util') +const { imageManager } = require('../../../utils/imageManager') Page({ data: { @@ -70,17 +71,10 @@ Page({ .then(res => { console.log('菜品列表数据:', res) - // 处理图片URL,确保是完整的绝对路径 + // 使用 imageManager 处理图片 const dishes = (res.results || res).map(dish => { if (dish.main_image) { - // 如果图片URL不是完整的HTTP/HTTPS URL,则拼接baseUrl - if (!dish.main_image.startsWith('http')) { - const app = getApp() - dish.main_image = `${app.globalData.baseUrl}${dish.main_image.startsWith('/') ? '' : '/'}${dish.main_image}` - } - // 添加时间戳参数避免缓存 - const timestamp = new Date().getTime() - dish.main_image = `${dish.main_image}?t=${timestamp}` + dish.main_image = imageManager.processImageUrl(dish.main_image) } return dish }) diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.js b/miniprogram/pages/gourmet/dish-detail/dish-detail.js index e9877f0..89455ff 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.js +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.js @@ -1,6 +1,7 @@ // 菜品详情页面 const { get, del } = require('../../../utils/request') const { showError, showLoading, hideLoading, showConfirm } = require('../../../utils/util') +const { imageManager } = require('../../../utils/imageManager') Page({ data: { @@ -8,8 +9,10 @@ Page({ dish: null, loading: true, currentImageIndex: 0, - isChef: false, // 是否为厨神(可以编辑) + isChef: false, // 是否为厨神 + isDishOwner: false, // 是否为菜品创建者 showShareMenu: false, // 是否显示分享菜单 + user: null, // 当前用户信息 categoryNames: { 'vegetable': '蔬菜', @@ -42,64 +45,83 @@ Page({ .then(res => { hideLoading() - // 检查当前用户是否为该菜品的厨神 + // 检查当前用户是否为厨神,以及是否为菜品创建者 const app = getApp() - const currentUser = app.globalData.user - const isChef = currentUser && currentUser.role === 'chef' && res.chef && currentUser.id === res.chef.id + const currentUser = app.globalData.userInfo + // 只要是厨神都可以看到编辑按钮 + const isChef = currentUser && currentUser.role === 'chef' + // 是否为菜品创建者 + const isDishOwner = isChef && res.chef && currentUser.id === res.chef.id - // 处理图片URL,确保是完整的绝对路径 + // 使用统一的图片管理器处理所有图片 + // 处理菜品图片 if (res.images && res.images.length > 0) { - res.images = res.images.map(image => { - if (image.image_url) { - // 如果图片URL不是完整的HTTP/HTTPS URL,则拼接baseUrl - if (!image.image_url.startsWith('http')) { - image.image_url = `${app.globalData.baseUrl}${image.image_url.startsWith('/') ? '' : '/'}${image.image_url}` - } - // 添加时间戳避免缓存 - const timestamp = new Date().getTime() - image.image_url = `${image.image_url}?t=${timestamp}` - } - return image - }) + res.images = imageManager.processImages(res.images) } else if (res.main_image) { - // 如果没有images数组,使用main_image - if (!res.main_image.startsWith('http')) { - res.main_image = `${app.globalData.baseUrl}${res.main_image.startsWith('/') ? '' : '/'}${res.main_image}` - } - const timestamp = new Date().getTime() - res.images = [{ image_url: `${res.main_image}?t=${timestamp}`, id: 1 }] + res.images = imageManager.processImages([{ image_url: res.main_image }]) + } else { + res.images = imageManager.processImages([]) } // 处理制作步骤图片 if (res.cooking_steps && res.cooking_steps.length > 0) { - res.cooking_steps = res.cooking_steps.map(step => { - if (step.image_url) { - if (!step.image_url.startsWith('http')) { - step.image_url = `${app.globalData.baseUrl}${step.image_url.startsWith('/') ? '' : '/'}${step.image_url}` - } - } - return step - }) + res.cooking_steps = imageManager.processCookingSteps(res.cooking_steps) + } + + // 处理厨神头像 + if (res.chef) { + res.chef = imageManager.processUserAvatar(res.chef) } // 格式化时间,只保留年月日 if (res.updated_at) { - res.updated_at = res.updated_at.split(' ')[0] // 取年月日部分 + // 处理ISO格式或标准格式 + const date = new Date(res.updated_at) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + res.updated_at = `${year}-${month}-${day}` } if (res.created_at) { - res.created_at = res.created_at.split(' ')[0] // 取年月日部分 + const date = new Date(res.created_at) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + res.created_at = `${year}-${month}-${day}` } this.setData({ dish: res, loading: false, - isChef: !!isChef // 确保不为 undefined + isChef: !!isChef, // 是否是厨神 + isDishOwner: !!isDishOwner, // 是否是菜品创建者 + user: currentUser // 保存当前用户信息 }) // 设置页面标题 wx.setNavigationBarTitle({ title: res.name || '菜品详情' }) + + // 异步预加载图片到本地(使用缓存) + if (res.images && res.images.length > 0) { + res.images.forEach((image, index) => { + if (image.image_url && !image.image_url.startsWith('/images/') && !image.image_url.startsWith('wxfile://')) { + imageManager.preloadImage(image.image_url) + .then(localPath => { + console.log(`菜品图片预加载成功 ${index}:`, localPath) + const dish = this.data.dish + if (dish && dish.images[index]) { + dish.images[index].image_url = localPath + this.setData({ dish }) + } + }) + .catch(err => { + console.error(`菜品图片预加载失败 ${index}:`, err) + }) + } + }) + } }) .catch(err => { hideLoading() @@ -169,18 +191,29 @@ Page({ return } + // 厨神都可以跳转到编辑页面 wx.navigateTo({ url: `/pages/chef/dish-edit/dish-edit?id=${this.data.dishId}` }) }, - // 删除菜品 + // 删除菜品(所有厨神可见,但只有创建者可以删除) deleteDish() { if (!this.data.isChef) { showError('只有厨神可以删除菜品') return } + // 如果不是菜品创建者,提示无法删除 + if (!this.data.isDishOwner) { + wx.showToast({ + title: '您不是该菜品的创建者,无法删除', + icon: 'none', + duration: 2000 + }) + return + } + const dishName = this.data.dish ? this.data.dish.name : '' showConfirm('确认删除', `删除后无法恢复,确定要删除菜品"${dishName}"吗?`) @@ -299,10 +332,11 @@ Page({ imageUrl: dish && dish.images && dish.images[index] ? dish.images[index].image_url : 'unknown' }) - // 设置默认图片 + // 使用默认图片 if (dish && dish.images && dish.images[index]) { dish.images[index].image_url = '/images/default-dish.png' this.setData({ dish }) } } }) + diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml index 1fea30e..8d79785 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml @@ -71,8 +71,8 @@ - {{item.name}} - {{item.quantity}} {{item.unit}} + {{item.name}} + {{item.quantity}} {{item.unit}} @@ -124,7 +124,7 @@ @@ -148,7 +148,7 @@ - - 还没有绑定厨神,去"计划"页面添加吧~ + + 还没有绑定厨神,去"计划"页面添加吧~ + 没有符合条件的菜品 diff --git a/miniprogram/pages/plan/plan.js b/miniprogram/pages/plan/plan.js index 876d011..f156804 100644 --- a/miniprogram/pages/plan/plan.js +++ b/miniprogram/pages/plan/plan.js @@ -100,6 +100,13 @@ Page({ }) }, + // 厨神:进入食神管理页面 + manageGourmets() { + wx.navigateTo({ + url: '/pages/chef/gourmets/gourmets' + }) + }, + // 食神:加载绑定的厨神列表 loadChefs() { this.setData({ loading: true }) diff --git a/miniprogram/pages/plan/plan.wxml b/miniprogram/pages/plan/plan.wxml index cb776ca..369f23c 100644 --- a/miniprogram/pages/plan/plan.wxml +++ b/miniprogram/pages/plan/plan.wxml @@ -3,6 +3,7 @@ 食神配餐计划 + 加载中... @@ -61,7 +62,7 @@ {{item.chef.nickname || '厨神' + item.chef.id}} - {{item.status === 'approved' ? '已绑定' : item.status === 'pending' ? '待审核' : '已拒绝'}} + {{item.status === 'accepted' ? '已绑定' : item.status === 'pending' ? '待审核' : item.status === 'rejected' ? '已拒绝' : item.status === 'cancelled' ? '已取消' : item.status}} diff --git a/miniprogram/pages/plan/plan.wxss b/miniprogram/pages/plan/plan.wxss index 7c0f30b..f3069de 100644 --- a/miniprogram/pages/plan/plan.wxss +++ b/miniprogram/pages/plan/plan.wxss @@ -19,7 +19,7 @@ font-weight: bold; } -.add-btn, .action-btn { +.add-btn, .action-btn, .manage-btn { padding: 10rpx 20rpx; background: #1890ff; color: white; diff --git a/miniprogram/pages/profile-edit/profile-edit.js b/miniprogram/pages/profile-edit/profile-edit.js index 4f88ee5..e51ad80 100644 --- a/miniprogram/pages/profile-edit/profile-edit.js +++ b/miniprogram/pages/profile-edit/profile-edit.js @@ -22,9 +22,11 @@ Page({ /** * 预加载头像到本地(带压缩功能) + * 注意:预加载的头像仅用于页面显示优化,不会更新formData.avatar_url + * 这样可以避免误将临时文件路径当作需要上传的新头像 */ preloadAvatar(url) { - console.log('Profile-Edit 开始预加载头像:', url) + console.log('Profile-Edit 开始预加载头像(仅用于显示):', url) wx.downloadFile({ url: url, @@ -34,8 +36,12 @@ Page({ if (res.statusCode === 200) { console.log('Profile-Edit 头像下载成功:', res.tempFilePath) - // 压缩头像 + // 压缩头像(仅用于显示,不更新formData) this.compressAvatar(res.tempFilePath) + + // 将压缩后的本地路径用于显示(但不保存到formData.avatar_url) + // 可以通过在image组件中使用一个独立的显示路径 + // 这里我们保持formData.avatar_url不变,image组件会直接使用服务器URL } else { console.error('Profile-Edit 头像预加载失败, 状态码:', res.statusCode) console.error('响应详情:', res) @@ -44,6 +50,7 @@ Page({ fail: (err) => { console.error('Profile-Edit 头像预加载失败:', err) console.error('错误详情:', err) + // 预加载失败不影响功能,image组件会直接使用服务器URL } }) }, @@ -60,25 +67,15 @@ Page({ success: (res) => { console.log('Profile-Edit 头像压缩成功:', res.tempFilePath) - // 更新头像URL为压缩后的本地路径 - const formData = this.data.formData - if (formData) { - formData.avatar_url = res.tempFilePath - this.setData({ formData }) - console.log('Profile-Edit 头像已更新为压缩后的本地路径:', res.tempFilePath) - console.log('Profile-Edit 更新后的表单数据:', formData) - } + // 预加载的头像仅用于显示,不更新formData.avatar_url + // formData.avatar_url应该保持原来的服务器URL,这样不会误触发上传 + // 临时文件路径仅用于页面显示,通过image组件的src直接使用 + console.log('Profile-Edit 头像压缩完成,仅用于显示,不更新formData') }, fail: (err) => { console.error('Profile-Edit 头像压缩失败:', err) - - // 压缩失败,使用原始文件 - const formData = this.data.formData - if (formData) { - formData.avatar_url = tempFilePath - this.setData({ formData }) - console.log('Profile-Edit 压缩失败,使用原始文件:', tempFilePath) - } + // 压缩失败不影响显示,使用原始下载的文件 + console.log('Profile-Edit 压缩失败,使用原始文件显示') } }) }, @@ -350,10 +347,20 @@ Page({ /** * 上传头像到服务器 + * 注意:此方法只能由用户主动选择头像时调用,不能由预加载等自动操作触发 */ uploadAvatar(filePath) { - console.log('=== 开始上传头像 ===') + console.log('=== 开始上传头像(用户主动操作) ===') console.log('文件路径:', filePath) + + // 安全检查:确保文件路径是临时文件(wxfile://或tmp_开头) + // 如果路径是HTTP URL,说明这是服务器上的头像,不应该重新上传 + if (filePath && (filePath.startsWith('http://') || filePath.startsWith('https://'))) { + console.warn('警告:尝试上传服务器URL作为头像,这是不允许的操作') + console.warn('文件路径:', filePath) + return + } + showLoading('上传中...') // 获取用户token -- Gitee From d41d083778d6ec1396b4ac79fd7a79ef81b515e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Fri, 31 Oct 2025 17:59:23 +0800 Subject: [PATCH 04/44] =?UTF-8?q?=E9=99=8D=E4=BD=8E=E6=8B=89=E5=8F=96?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E7=9A=84=E9=A2=91=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/dishes/serializers.py | 3 - backend/meal_architect/middleware.py | 109 +++++++++++++++++++++++++++ backend/meal_architect/settings.py | 1 + miniprogram/pages/menu/menu.js | 26 +++++-- miniprogram/pages/menu/menu.wxml | 4 +- miniprogram/utils/imageManager.js | 13 +++- 6 files changed, 141 insertions(+), 15 deletions(-) create mode 100644 backend/meal_architect/middleware.py diff --git a/backend/dishes/serializers.py b/backend/dishes/serializers.py index 1ce3c75..5e9a744 100644 --- a/backend/dishes/serializers.py +++ b/backend/dishes/serializers.py @@ -235,7 +235,6 @@ class DishListSerializer(serializers.ModelSerializer): if request: # 确保URL是完整的绝对路径 image_url = request.build_absolute_uri(first_image.image.url) - print(f"构建的图片URL: {image_url}") return image_url else: # 如果没有request上下文,手动构建完整URL @@ -247,9 +246,7 @@ class DishListSerializer(serializers.ModelSerializer): image_url = f"{base_url.rstrip('/')}{first_image.image.url}" else: image_url = f"{base_url}{first_image.image.url}" - print(f"手动构建的图片URL: {image_url}") return image_url - print(f"菜品 {obj.name} 没有图片") return None diff --git a/backend/meal_architect/middleware.py b/backend/meal_architect/middleware.py new file mode 100644 index 0000000..e4c6ca4 --- /dev/null +++ b/backend/meal_architect/middleware.py @@ -0,0 +1,109 @@ +""" +自定义中间件 +""" +import os +import hashlib +from django.utils.deprecation import MiddlewareMixin +from django.http import HttpResponse, HttpResponseNotModified +from django.conf import settings +from django.utils.http import http_date + + +class MediaCacheMiddleware(MiddlewareMixin): + """ + 为媒体文件添加HTTP缓存头和支持304响应 + 减少服务器压力和带宽消耗 + """ + def process_request(self, request): + """处理请求,检查缓存验证头""" + # 只处理媒体文件和静态文件 + if not (request.path.startswith('/media/') or request.path.startswith('/static/')): + return None + + # 获取文件路径 + if request.path.startswith('/media/'): + file_path = os.path.join(settings.MEDIA_ROOT, request.path.replace('/media/', '', 1)) + else: + file_path = os.path.join(settings.STATIC_ROOT, request.path.replace('/static/', '', 1)) + + # 检查文件是否存在 + if not os.path.exists(file_path) or not os.path.isfile(file_path): + return None + + # 获取文件修改时间 + try: + stat = os.stat(file_path) + mtime = stat.st_mtime + file_size = stat.st_size + except OSError: + return None + + # 生成ETag(基于文件路径、修改时间和大小) + etag = self._generate_etag(file_path, mtime, file_size) + last_modified = http_date(mtime) + + # 检查If-None-Match(ETag验证) + if_none_match = request.META.get('HTTP_IF_NONE_MATCH', '') + if if_none_match and etag in if_none_match: + # 资源未修改,返回304 + response = HttpResponseNotModified() + response['ETag'] = etag + response['Last-Modified'] = last_modified + return response + + # 检查If-Modified-Since(Last-Modified验证) + if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE', '') + if if_modified_since: + try: + from django.utils.http import parse_http_date + if_modified_since_time = parse_http_date(if_modified_since) + if mtime <= if_modified_since_time: + # 资源未修改,返回304 + response = HttpResponseNotModified() + response['ETag'] = etag + response['Last-Modified'] = last_modified + return response + except (ValueError, TypeError): + pass + + # 将ETag和Last-Modified存储到request中,供process_response使用 + request._media_etag = etag + request._media_last_modified = last_modified + + return None + + def process_response(self, request, response): + """处理响应,添加缓存头""" + # 只对媒体文件添加缓存头 + if request.path.startswith('/media/'): + # 设置缓存时间为1年(31536000秒) + response['Cache-Control'] = 'public, max-age=31536000, immutable' + response['Expires'] = 'Thu, 31 Dec 2025 23:59:59 GMT' + + # 添加ETag和Last-Modified(如果之前计算过) + if hasattr(request, '_media_etag'): + response['ETag'] = request._media_etag + if hasattr(request, '_media_last_modified'): + response['Last-Modified'] = request._media_last_modified + + # 对静态文件也添加缓存头 + elif request.path.startswith('/static/'): + response['Cache-Control'] = 'public, max-age=31536000, immutable' + response['Expires'] = 'Thu, 31 Dec 2025 23:59:59 GMT' + + # 添加ETag和Last-Modified(如果之前计算过) + if hasattr(request, '_media_etag'): + response['ETag'] = request._media_etag + if hasattr(request, '_media_last_modified'): + response['Last-Modified'] = request._media_last_modified + + return response + + def _generate_etag(self, file_path, mtime, file_size): + """生成ETag(基于文件路径、修改时间和大小)""" + # 使用文件路径、修改时间和大小生成ETag + # 这样如果文件未改变,ETag就不会变 + content = f"{file_path}:{mtime}:{file_size}" + etag = hashlib.md5(content.encode('utf-8')).hexdigest() + return f'"{etag}"' + diff --git a/backend/meal_architect/settings.py b/backend/meal_architect/settings.py index d2da681..001168c 100644 --- a/backend/meal_architect/settings.py +++ b/backend/meal_architect/settings.py @@ -50,6 +50,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'meal_architect.middleware.MediaCacheMiddleware', # 媒体文件缓存中间件 ] ROOT_URLCONF = 'meal_architect.urls' diff --git a/miniprogram/pages/menu/menu.js b/miniprogram/pages/menu/menu.js index ee9de00..af9cdd6 100644 --- a/miniprogram/pages/menu/menu.js +++ b/miniprogram/pages/menu/menu.js @@ -19,6 +19,7 @@ Page({ // 食神数据 dishes: [], + filteredDishes: [], // 过滤后的菜品列表 chefs: [], selectedChef: 'all', selectedCategory: 'all', @@ -89,10 +90,14 @@ Page({ .then(res => { console.log('菜单数据:', res) - // 使用 imageManager 处理图片 + // 使用 imageManager 处理图片(移除时间戳,确保URL稳定便于缓存) const dishes = (res.dishes || []).map(dish => { if (dish.main_image) { - dish.main_image = imageManager.processImageUrl(dish.main_image) + // 处理图片URL,确保稳定(移除查询参数,便于缓存) + let imageUrl = imageManager.processImageUrl(dish.main_image) + // 确保URL中没有时间戳参数,便于小程序缓存 + imageUrl = imageUrl.split('?')[0] + dish.main_image = imageUrl } return dish }) @@ -103,6 +108,9 @@ Page({ loading: false, refreshing: false }) + + // 应用筛选 + this.applyFilters() }) .catch(err => { this.setData({ @@ -167,18 +175,23 @@ Page({ this.setData({ selectedChef: chef === 'all' ? 'all' : parseInt(chef) || chef }) + // 应用筛选 + this.applyFilters() }, // 食神:筛选分类 filterCategory(e) { const category = e.currentTarget.dataset.category this.setData({ selectedCategory: category }) + // 应用筛选 + this.applyFilters() }, - // 获取过滤后的菜品 - getFilteredDishes() { + // 应用筛选条件 + applyFilters() { let dishes = this.data.dishes || [] + // 按厨神筛选 if (this.data.selectedChef !== 'all') { const chefId = typeof this.data.selectedChef === 'string' ? parseInt(this.data.selectedChef) @@ -186,16 +199,17 @@ Page({ dishes = dishes.filter(d => { const dishChefId = typeof d.chef_id === 'string' ? parseInt(d.chef_id) - : d.chef_id + : (d.chef_id || d.chef?.id) return dishChefId === chefId }) } + // 按分类筛选 if (this.data.selectedCategory !== 'all') { dishes = dishes.filter(d => d.category === this.data.selectedCategory) } - return dishes + this.setData({ filteredDishes: dishes }) }, selectRole() { diff --git a/miniprogram/pages/menu/menu.wxml b/miniprogram/pages/menu/menu.wxml index 88d06c4..9f73b29 100644 --- a/miniprogram/pages/menu/menu.wxml +++ b/miniprogram/pages/menu/menu.wxml @@ -59,7 +59,7 @@ 加载中... - - + 还没有绑定厨神,去"计划"页面添加吧~ 没有符合条件的菜品 diff --git a/miniprogram/utils/imageManager.js b/miniprogram/utils/imageManager.js index 183af25..61f518a 100644 --- a/miniprogram/utils/imageManager.js +++ b/miniprogram/utils/imageManager.js @@ -142,13 +142,18 @@ class ImageManager { url: processedUrl, timeout: 30000, success: (res) => { - if (res.statusCode === 200) { - console.log('图片下载成功:', res.tempFilePath) + // 200: 正常下载,304: 使用缓存(服务器返回304时,微信会自动使用本地缓存) + if (res.statusCode === 200 || res.statusCode === 304) { + if (res.statusCode === 304) { + console.log('图片未修改,使用缓存:', processedUrl) + } else { + console.log('图片下载成功:', res.tempFilePath) + } resolve(res.tempFilePath) - // 保存到缓存 - if (useCache) { + // 保存到缓存(只有200时才需要保存,304时微信已经使用了缓存) + if (useCache && res.statusCode === 200) { try { this.setCachedImage(processedUrl, res.tempFilePath) } catch (e) { -- Gitee From c870af44514cdbabd33f3a145bb20caa0d188822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Mon, 3 Nov 2025 10:35:07 +0800 Subject: [PATCH 05/44] =?UTF-8?q?=E9=85=8D=E9=A4=90=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E5=92=8C=E9=80=89=E6=8B=A9=E8=8F=9C=E5=93=81=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=8A=A5=E9=94=99=E9=97=AE=E9=A2=98=E3=80=81=E3=80=82=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/plans/views.py | 18 +++++++++++++---- .../pages/gourmet/daily-plan/daily-plan.wxml | 10 ++++++---- .../pages/gourmet/daily-plan/daily-plan.wxss | 8 ++++++-- .../gourmet/dish-selector/dish-selector.wxss | 20 ++++++++++++++----- 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/backend/plans/views.py b/backend/plans/views.py index 9ebf7fc..b4427b5 100644 --- a/backend/plans/views.py +++ b/backend/plans/views.py @@ -861,8 +861,13 @@ def dish_selector(request): ).prefetch_related('images', 'ingredients') # 如果指定了厨神 - if chef_id and int(chef_id) in approved_chefs: - dishes_query = dishes_query.filter(chef_id=chef_id) + if chef_id and chef_id != 'null' and chef_id != '': + try: + chef_id_int = int(chef_id) + if chef_id_int in approved_chefs: + dishes_query = dishes_query.filter(chef_id=chef_id_int) + except (ValueError, TypeError): + pass # 如果指定了分类 if category: @@ -940,8 +945,13 @@ def meal_set_selector(request): ).prefetch_related('dishes') # 如果指定了厨神 - if chef_id and int(chef_id) in approved_chefs: - meal_sets_query = meal_sets_query.filter(chef_id=chef_id) + if chef_id and chef_id != 'null' and chef_id != '': + try: + chef_id_int = int(chef_id) + if chef_id_int in approved_chefs: + meal_sets_query = meal_sets_query.filter(chef_id=chef_id_int) + except (ValueError, TypeError): + pass # 如果有关键词搜索 if keyword: diff --git a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml index 860fde6..fed9cda 100644 --- a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml +++ b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml @@ -5,10 +5,12 @@ 配餐计划 {{date}} - - - - + + + + + + diff --git a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss index 51cf905..84fcdb4 100644 --- a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss +++ b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss @@ -34,10 +34,14 @@ color: #999; } -.actions { +.actions-row { display: flex; gap: 12rpx; - flex-shrink: 0; + background: #fff; + border-radius: 16rpx; + padding: 20rpx 30rpx; + margin-bottom: 20rpx; + box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } .action-btn { diff --git a/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss b/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss index 81dded1..49059f0 100644 --- a/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss +++ b/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss @@ -156,10 +156,15 @@ .formula-tag { background: rgba(255, 255, 255, 0.2); - padding: 10rpx 20rpx; - border-radius: 20rpx; - font-size: 26rpx; + padding: 6rpx 16rpx; + border-radius: 16rpx; + font-size: 24rpx; backdrop-filter: blur(10rpx); + display: flex; + align-items: center; + justify-content: center; + line-height: 1.2; + height: 40rpx; } /* 营养搭配状态 */ @@ -223,9 +228,14 @@ .required { background: #ff4d4f; color: white; - padding: 4rpx 12rpx; - border-radius: 12rpx; + padding: 4rpx 10rpx; + border-radius: 10rpx; font-size: 20rpx; + display: flex; + align-items: center; + justify-content: center; + line-height: 1.2; + height: 32rpx; } .required.completed { -- Gitee From 4ee1a1a493ab89b748f5f3477006f821c9f0eeac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Mon, 3 Nov 2025 10:44:42 +0800 Subject: [PATCH 06/44] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E8=8F=9C=E5=93=81=E9=A1=B5=E9=9D=A2=E7=9A=84=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gourmet/dish-selector/dish-selector.js | 67 +++++++++-- .../gourmet/dish-selector/dish-selector.wxml | 60 +++++++--- .../gourmet/dish-selector/dish-selector.wxss | 113 ++++++++++++++++-- 3 files changed, 207 insertions(+), 33 deletions(-) diff --git a/miniprogram/pages/gourmet/dish-selector/dish-selector.js b/miniprogram/pages/gourmet/dish-selector/dish-selector.js index 19e03d8..f0a2b3f 100644 --- a/miniprogram/pages/gourmet/dish-selector/dish-selector.js +++ b/miniprogram/pages/gourmet/dish-selector/dish-selector.js @@ -14,10 +14,23 @@ Page({ tabIndex: 0, loading: false, + // 已绑定厨师列表 + boundChefs: [], + selectedChefId: null, // 选中的厨师ID,null表示全部 + loadingChefs: false, + // 筛选条件 - selectedChef: null, + selectedChef: null, // 向后端传递的参数 selectedCategory: null, + selectedCategoryLabel: '', searchKeyword: '', + categoryOptions: [ + { value: '', label: '全部分类' }, + { value: 'vegetable', label: '蔬菜' }, + { value: 'protein', label: '蛋白质' }, + { value: 'carb', label: '碳水' }, + { value: 'fat', label: '脂肪' } + ], // 配餐公式 mealFormulas: { @@ -47,6 +60,44 @@ Page({ mealType: options.mealType, mode: options.mode || 'dish' }) + // 先加载已绑定厨师列表 + this.loadBoundChefs() + // 然后加载菜品数据 + this.loadData() + }, + + // 加载已绑定厨师列表 + loadBoundChefs() { + this.setData({ loadingChefs: true }) + + get('/api/users/bindings/bound/') + .then(res => { + console.log('已绑定厨师列表:', res) + this.setData({ + boundChefs: res.users || [], + loadingChefs: false + }) + }) + .catch(err => { + console.error('加载已绑定厨师失败:', err) + this.setData({ + boundChefs: [], + loadingChefs: false + }) + }) + }, + + // 选择厨师 + selectChef(e) { + const chefId = e.currentTarget.dataset.id + const chefIdNum = chefId === 'null' ? null : parseInt(chefId) + + this.setData({ + selectedChefId: chefIdNum, + selectedChef: chefIdNum // 用于API请求 + }) + + // 重新加载菜品数据 this.loadData() }, @@ -54,7 +105,7 @@ Page({ if (this.data.loading) return this.setData({ loading: true }) - console.log('开始加载菜品和套餐数据') + console.log('开始加载菜品和套餐数据,选中厨师ID:', this.data.selectedChef) // 使用新的菜品选择器API Promise.all([ @@ -175,13 +226,13 @@ Page({ }, // 筛选条件变化 - onChefChange(e) { - this.setData({ selectedChef: e.detail.value }) - this.loadData() - }, - onCategoryChange(e) { - this.setData({ selectedCategory: e.detail.value }) + const index = parseInt(e.detail.value) + const categoryOption = this.data.categoryOptions[index] + this.setData({ + selectedCategory: categoryOption.value || null, + selectedCategoryLabel: categoryOption.label + }) this.loadData() }, diff --git a/miniprogram/pages/gourmet/dish-selector/dish-selector.wxml b/miniprogram/pages/gourmet/dish-selector/dish-selector.wxml index 05d5e94..a1511b4 100644 --- a/miniprogram/pages/gourmet/dish-selector/dish-selector.wxml +++ b/miniprogram/pages/gourmet/dish-selector/dish-selector.wxml @@ -11,6 +11,41 @@ {{nutritionTips[mealType]}} + + + 选择厨师 + + + + + 全部 + + 全部厨师 + + + + {{item.nickname || '厨师' + item.id}} + + + + + + + 去绑定 + + + + + 选择菜品 @@ -20,23 +55,16 @@ - - - 厨神 - 全部 - - - - + 分类 - 全部 + {{selectedCategoryLabel || '全部分类'}} - + 🔍 @@ -46,9 +74,9 @@ 配餐公式 - - {{categoryNames[category]}} - + + {{categoryNames[category]}} + @@ -68,9 +96,9 @@ {{categoryNames[category]}} - - {{selectedDishes[category] ? '✓' : '必选'}} - + + {{selectedDishes[category] ? '✓' : '必选'}} + diff --git a/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss b/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss index 49059f0..d8cf257 100644 --- a/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss +++ b/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss @@ -48,6 +48,90 @@ flex: 1; } +/* 已绑定厨师列表样式 */ +.chefs-section { + background: #fff; + border-radius: 16rpx; + padding: 24rpx 0 24rpx 24rpx; + margin-bottom: 20rpx; + box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); +} + +.section-title { + font-size: 28rpx; + font-weight: bold; + color: #333; + margin-bottom: 16rpx; + padding-right: 24rpx; +} + +.chefs-scroll { + width: 100%; +} + +.chefs-list { + display: flex; + gap: 16rpx; + padding-right: 24rpx; + flex-wrap: nowrap; +} + +.chef-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8rpx; + padding: 12rpx; + border-radius: 12rpx; + background: #fafafa; + border: 2rpx solid transparent; + min-width: 120rpx; + transition: all 0.3s; +} + +.chef-card.active { + background: #e6f7ff; + border-color: #1890ff; +} + +.chef-avatar-wrapper { + width: 80rpx; + height: 80rpx; + border-radius: 50%; + background: linear-gradient(135deg, #1890ff, #40a9ff); + display: flex; + align-items: center; + justify-content: center; +} + +.chef-avatar-text { + font-size: 28rpx; + color: #fff; + font-weight: bold; +} + +.chef-avatar { + width: 80rpx; + height: 80rpx; + border-radius: 50%; + border: 2rpx solid #e9ecef; +} + +.chef-name { + font-size: 24rpx; + color: #666; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120rpx; +} + +.chef-card.active .chef-name { + color: #1890ff; + font-weight: bold; +} + /* 标签页样式 */ .tabs { display: flex; @@ -156,15 +240,21 @@ .formula-tag { background: rgba(255, 255, 255, 0.2); - padding: 6rpx 16rpx; border-radius: 16rpx; - font-size: 24rpx; backdrop-filter: blur(10rpx); - display: flex; + display: inline-flex; align-items: center; justify-content: center; - line-height: 1.2; height: 40rpx; + padding: 0 16rpx; + min-width: 60rpx; +} + +.formula-tag-text { + font-size: 24rpx; + color: white; + line-height: 1; + text-align: center; } /* 营养搭配状态 */ @@ -227,15 +317,20 @@ .required { background: #ff4d4f; - color: white; - padding: 4rpx 10rpx; border-radius: 10rpx; - font-size: 20rpx; - display: flex; + display: inline-flex; align-items: center; justify-content: center; - line-height: 1.2; height: 32rpx; + padding: 0 10rpx; + min-width: 44rpx; +} + +.required-text { + font-size: 20rpx; + color: white; + line-height: 1; + text-align: center; } .required.completed { -- Gitee From 88abb755feb1159aac8dea7f3d798df3424e8b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Mon, 3 Nov 2025 11:25:26 +0800 Subject: [PATCH 07/44] =?UTF-8?q?=E9=85=8D=E9=A4=90=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/plans/views.py | 2 +- .../pages/gourmet/daily-plan/daily-plan.js | 49 +++ .../pages/gourmet/daily-plan/daily-plan.wxml | 59 +-- .../pages/gourmet/daily-plan/daily-plan.wxss | 58 ++- .../gourmet/dish-selector/dish-selector.js | 397 +++++++++++++++--- .../gourmet/dish-selector/dish-selector.json | 6 +- .../gourmet/dish-selector/dish-selector.wxml | 140 +++--- .../gourmet/dish-selector/dish-selector.wxss | 145 ++++++- 8 files changed, 681 insertions(+), 175 deletions(-) diff --git a/backend/plans/views.py b/backend/plans/views.py index b4427b5..d07767e 100644 --- a/backend/plans/views.py +++ b/backend/plans/views.py @@ -907,7 +907,7 @@ def dish_selector(request): result['dishes_by_category'][cat] = { 'category_name': dict(Dish.CATEGORY_CHOICES)[cat], 'is_recommended': cat in recommended, - 'dishes': DishListSerializer(dish_list, many=True).data + 'dishes': DishListSerializer(dish_list, many=True, context={'request': request}).data } return Response(result) diff --git a/miniprogram/pages/gourmet/daily-plan/daily-plan.js b/miniprogram/pages/gourmet/daily-plan/daily-plan.js index 139dcab..8753467 100644 --- a/miniprogram/pages/gourmet/daily-plan/daily-plan.js +++ b/miniprogram/pages/gourmet/daily-plan/daily-plan.js @@ -273,6 +273,55 @@ Page({ // 隐藏快速操作 hideQuickActions() { this.setData({ showQuickActions: false }) + }, + + // 从配餐计划中删除菜品 + deleteDishFromPlan(e) { + const mealType = e.currentTarget.dataset.mealType + const dishId = parseInt(e.currentTarget.dataset.dishId) + const planId = e.currentTarget.dataset.planId + const mealName = this.data.mealTypeNames[mealType] + + wx.showModal({ + title: '确认删除', + content: `确定要从${mealName}中删除这个菜品吗?`, + success: (res) => { + if (res.confirm) { + showLoading('删除中...') + + // 获取当前计划 + get(`/api/plans/plans/${planId}/`) + .then(plan => { + // 从菜品列表中移除 + const dishIds = plan.dishes + .map(d => d.id) + .filter(id => id !== dishId) + + // 更新计划 + return put(`/api/plans/plans/${planId}/`, { + date: plan.date, + meal_type: plan.meal_type, + dish_ids: dishIds + }) + }) + .then(() => { + hideLoading() + showSuccess('删除成功') + this.loadPlans() + this.loadNutritionSummary() + }) + .catch(err => { + hideLoading() + showError(err.message || '删除失败') + }) + } + } + }) + }, + + // 阻止事件冒泡 + stopPropagation(e) { + // 空函数,仅用于阻止事件冒泡 } }) diff --git a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml index fed9cda..09158ca 100644 --- a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml +++ b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml @@ -13,44 +13,14 @@ - - - 营养分析 - - - {{mealTypeNames[item]}} - - {{nutritionSummary.nutrition_status[item] && nutritionSummary.nutrition_status[item].is_valid ? '✓' : '✗'}} - - - - - 建议: - {{item}} - - - - + {{mealTypeNames[item]}} - - {{nutritionSummary.nutrition_status[item] && nutritionSummary.nutrition_status[item].is_valid ? '✓' : '✗'}} - - - - - - + > @@ -63,14 +33,17 @@ - - - - {{dish.name}} - {{dish.category_display}} + + + + + {{dish.name}} + {{dish.category_display}} + - - 查看详情 + + 修改 + 删除 @@ -128,6 +101,14 @@ + + + 营养分析 + + 功能待开发 + + + 加载中... diff --git a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss index 84fcdb4..627d743 100644 --- a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss +++ b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss @@ -58,15 +58,26 @@ background: #ff4d4f; } -/* 营养汇总样式 */ -.nutrition-summary { +/* 营养汇总样式(底部) */ +.nutrition-summary-bottom { background: #fff; border-radius: 16rpx; padding: 30rpx; + margin-top: 20rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } +.summary-placeholder { + text-align: center; + padding: 40rpx 0; +} + +.placeholder-text { + font-size: 28rpx; + color: #999; +} + .summary-title { font-size: 32rpx; font-weight: bold; @@ -141,6 +152,18 @@ justify-content: space-between; align-items: center; margin-bottom: 20rpx; + padding: 12rpx; + border-radius: 8rpx; + transition: background 0.3s; +} + +.meal-header:active { + background: #f5f5f5; +} + +.meal-arrow { + font-size: 32rpx; + color: #999; } .meal-info { @@ -232,6 +255,37 @@ gap: 16rpx; } +.dish-content { + display: flex; + align-items: center; + flex: 1; + gap: 16rpx; +} + +.dish-swipe-actions { + display: flex; + align-items: center; + gap: 8rpx; + margin-left: auto; +} + +.dish-action-btn { + padding: 8rpx 16rpx; + font-size: 24rpx; + color: #fff; + border-radius: 6rpx; + white-space: nowrap; + line-height: 1.2; +} + +.edit-btn { + background: #1890ff; +} + +.delete-btn { + background: #ff4d4f; +} + .dish-image { width: 80rpx; height: 80rpx; diff --git a/miniprogram/pages/gourmet/dish-selector/dish-selector.js b/miniprogram/pages/gourmet/dish-selector/dish-selector.js index f0a2b3f..f0da481 100644 --- a/miniprogram/pages/gourmet/dish-selector/dish-selector.js +++ b/miniprogram/pages/gourmet/dish-selector/dish-selector.js @@ -9,16 +9,21 @@ Page({ mode: 'dish', // 'dish' 或 'mealSet' dishes: [], mealSets: [], - selectedDishes: {}, // 按分类存储选择的菜品 {category: dishId} + selectedDishes: {}, // 按分类存储选择的菜品 {category: [dishId1, dishId2, ...]} selectedMealSetId: null, tabIndex: 0, loading: false, - // 已绑定厨师列表 + // 已绑定厨神列表 boundChefs: [], - selectedChefId: null, // 选中的厨师ID,null表示全部 + selectedChefId: null, // 选中的厨神ID,null表示全部 + selectedChefName: '', // 选中的厨神名称 loadingChefs: false, + // 浏览模式 + viewMode: 'formula', // 'formula' 或 'browse' + allDishes: [], // 浏览模式下的所有菜品列表 + // 筛选条件 selectedChef: null, // 向后端传递的参数 selectedCategory: null, @@ -50,7 +55,11 @@ Page({ breakfast: '早餐要清淡易消化,建议选择蔬菜和蛋白质', lunch: '午餐要营养均衡,建议包含蔬菜、蛋白质、碳水化合物和适量脂肪', dinner: '晚餐要清淡,建议选择蔬菜、蛋白质和适量碳水化合物' - } + }, + + // 统计信息 + totalSelectedCount: 0, + selectedCategoriesCount: 0 }, onLoad(options) { @@ -87,22 +96,152 @@ Page({ }) }, - // 选择厨师 + // 选择厨神 selectChef(e) { const chefId = e.currentTarget.dataset.id const chefIdNum = chefId === 'null' ? null : parseInt(chefId) + // 获取厨神名称 + let chefName = '' + if (chefIdNum) { + const chef = this.data.boundChefs.find(c => c.id === chefIdNum) + chefName = chef ? (chef.nickname || '厨神' + chefIdNum) : '' + } + this.setData({ selectedChefId: chefIdNum, - selectedChef: chefIdNum // 用于API请求 + selectedChef: chefIdNum, // 用于API请求 + selectedChefName: chefName, + viewMode: chefIdNum ? 'browse' : 'formula' // 选择厨神后默认进入浏览模式 }) // 重新加载菜品数据 this.loadData() + // 如果选择了厨神,同时加载所有菜品 + if (chefIdNum) { + this.loadAllDishes() + } + }, + + // 切换浏览模式 + switchViewMode(e) { + const mode = e.currentTarget.dataset.mode + this.setData({ viewMode: mode }) + + // 如果切换到浏览模式且还没有加载所有菜品,则加载 + if (mode === 'browse' && this.data.allDishes.length === 0) { + this.loadAllDishes() + } + }, + + // 加载指定厨神的所有菜品(浏览模式) + loadAllDishes() { + if (!this.data.selectedChefId) return + + // 如果已经有按分类分组的菜品数据,直接从中提取 + const dishesByCategory = this.data.dishes + if (dishesByCategory && Object.keys(dishesByCategory).length > 0) { + this.updateAllDishesFromCategoryData(dishesByCategory) + return + } + + // 否则使用 dish-selector API 获取数据 + get('/api/plans/dish-selector/', { + meal_type: this.data.mealType, + chef_id: this.data.selectedChefId + }) + .then(res => { + console.log('所有菜品数据(从dish-selector):', res) + const dishesByCategory = res.dishes_by_category || {} + this.updateAllDishesFromCategoryData(dishesByCategory) + }) + .catch(err => { + console.error('加载所有菜品失败:', err) + this.setData({ + allDishes: [] + }) + }) + }, + + // 更新所有菜品列表的选中状态 + updateAllDishesSelection() { + const selectedDishIds = this.flattenSelectedDishes(this.data.selectedDishes) + const allDishes = this.data.allDishes.map(dish => ({ + ...dish, + isSelected: selectedDishIds.includes(dish.id) + })) + this.setData({ allDishes }) + }, + + // 将选中的菜品数据结构扁平化为数组 + flattenSelectedDishes(selectedDishes) { + const allDishIds = [] + Object.values(selectedDishes).forEach(dishes => { + if (Array.isArray(dishes)) { + allDishIds.push(...dishes) + } else { + // 兼容旧数据格式(单个ID) + allDishIds.push(dishes) + } + }) + return allDishIds + }, + + // 从浏览模式选择菜品(支持同一分类多选) + toggleDishFromBrowse(e) { + const dishId = parseInt(e.currentTarget.dataset.id) + const category = e.currentTarget.dataset.category + + const selectedDishes = { ...this.data.selectedDishes } + + // 确保分类字段是数组 + if (!selectedDishes[category]) { + selectedDishes[category] = [] + } + + const dishIndex = selectedDishes[category].indexOf(dishId) + const isSelected = dishIndex > -1 + + if (isSelected) { + // 取消选择:从数组中移除 + selectedDishes[category].splice(dishIndex, 1) + // 如果数组为空,删除该分类 + if (selectedDishes[category].length === 0) { + delete selectedDishes[category] + } + } else { + // 添加到数组(多选,不再提示替换) + selectedDishes[category].push(dishId) + // 提示已添加 + showSuccess('已添加') + } + + // 更新浏览列表中的选中状态 + const allDishes = this.data.allDishes.map(dish => ({ + ...dish, + isSelected: dish.id === dishId ? !isSelected : dish.isSelected + })) + + // 同时更新配餐公式模式中的选中状态 + const dishes = { ...this.data.dishes } + if (dishes[category] && dishes[category].dishes) { + dishes[category].dishes = dishes[category].dishes.map(dish => ({ + ...dish, + isSelected: dish.id === dishId ? !isSelected : dish.isSelected + })) + } + + this.setData({ + selectedDishes, + allDishes, + dishes + }, () => { + this.updateStatistics() + }) }, loadData() { - if (this.data.loading) return + if (this.data.loading) return Promise.resolve() this.setData({ loading: true }) console.log('开始加载菜品和套餐数据,选中厨师ID:', this.data.selectedChef) @@ -130,22 +269,89 @@ Page({ console.log('菜品数据加载成功:', dishesRes) console.log('套餐数据加载成功:', mealsRes) + // 处理菜品数据,确保图片URL正确 + const dishesByCategory = dishesRes.dishes_by_category || {} + const processedDishes = {} + Object.keys(dishesByCategory).forEach(category => { + const categoryData = dishesByCategory[category] + if (categoryData && categoryData.dishes) { + processedDishes[category] = { + ...categoryData, + dishes: categoryData.dishes.map(dish => { + // 检查是否已选中 + const categorySelected = this.data.selectedDishes[category] || [] + const isSelected = Array.isArray(categorySelected) + ? categorySelected.includes(dish.id) + : categorySelected === dish.id + + return { + ...dish, + // 确保图片URL存在 + main_image: dish.main_image || (dish.images && dish.images.length > 0 ? dish.images[0].image : null), + // 标记选中状态 + isSelected + } + }) + } + } else { + processedDishes[category] = categoryData + } + }) + this.setData({ - dishes: dishesRes.dishes_by_category || {}, + dishes: processedDishes, mealSets: mealsRes.meal_sets || [], loading: false + }, () => { + this.updateStatistics() }) + // 如果选择了厨神且在浏览模式,更新所有菜品列表(合并从 dish-selector API 获取的数据) + if (this.data.selectedChefId && this.data.viewMode === 'browse') { + this.updateAllDishesFromCategoryData(processedDishes) + } + // 如果两个请求都失败了,显示错误 if (dishesRes.error && mealsRes.error) { showError(dishesRes.error || '加载失败') } this.loadExistingPlan() + return Promise.resolve() }).catch(err => { console.error('数据加载异常:', err) this.setData({ loading: false }) showError(err.message || '加载失败') + return Promise.reject(err) + }) + }, + + // 更新统计信息 + updateStatistics() { + const selectedDishes = this.data.selectedDishes + const mealFormula = this.data.mealFormulas[this.data.mealType] || [] + + // 计算已选分类数 + let selectedCategoriesCount = 0 + mealFormula.forEach(category => { + if (selectedDishes[category] && selectedDishes[category].length > 0) { + selectedCategoriesCount++ + } + }) + + // 计算总选中菜品数 + let totalSelectedCount = 0 + Object.values(selectedDishes).forEach(dishes => { + if (Array.isArray(dishes)) { + totalSelectedCount += dishes.length + } else if (dishes) { + totalSelectedCount += 1 + } + }) + + this.setData({ + totalSelectedCount, + selectedCategoriesCount }) }, @@ -166,7 +372,10 @@ Page({ } else if (plan.dishes && plan.dishes.length > 0) { const selectedDishes = {} plan.dishes.forEach(dish => { - selectedDishes[dish.category] = dish.id + if (!selectedDishes[dish.category]) { + selectedDishes[dish.category] = [] + } + selectedDishes[dish.category].push(dish.id) }) this.setData({ selectedDishes }) } @@ -202,22 +411,48 @@ Page({ } }, - // 选择菜品 + // 选择菜品(支持同一分类多选) toggleDish(e) { const dishId = parseInt(e.currentTarget.dataset.id) const category = e.currentTarget.dataset.category const selectedDishes = { ...this.data.selectedDishes } - if (selectedDishes[category] === dishId) { - // 取消选择 - delete selectedDishes[category] + // 确保分类字段是数组 + if (!selectedDishes[category]) { + selectedDishes[category] = [] + } + + const dishIndex = selectedDishes[category].indexOf(dishId) + const isSelected = dishIndex > -1 + + if (isSelected) { + // 取消选择:从数组中移除 + selectedDishes[category].splice(dishIndex, 1) + // 如果数组为空,删除该分类 + if (selectedDishes[category].length === 0) { + delete selectedDishes[category] + } } else { - // 选择或替换 - selectedDishes[category] = dishId + // 添加到数组(多选) + selectedDishes[category].push(dishId) + } + + // 更新菜品列表中的选中状态 + const dishes = { ...this.data.dishes } + if (dishes[category] && dishes[category].dishes) { + dishes[category].dishes = dishes[category].dishes.map(dish => ({ + ...dish, + isSelected: dish.id === dishId ? !isSelected : dish.isSelected + })) } - this.setData({ selectedDishes }) + this.setData({ + selectedDishes, + dishes + }, () => { + this.updateStatistics() + }) }, // 选择套餐 @@ -248,53 +483,55 @@ Page({ save() { const { date, mealType, selectedDishes, selectedMealSetId, tabIndex } = this.data - if (tabIndex === 0) { - // 按菜品选择 - const requiredCategories = this.getRequiredCategories() - const selectedCategories = Object.keys(selectedDishes) - - // 检查是否选择了所有必需分类 - const missingCategories = requiredCategories.filter(cat => - !selectedCategories.includes(cat) - ) - - if (missingCategories.length > 0) { - const missingNames = missingCategories.map(cat => this.data.categoryNames[cat]) - return showError(`请选择${missingNames.join('、')}`) - } - - // 检查是否有多余分类 - const extraCategories = selectedCategories.filter(cat => - !requiredCategories.includes(cat) - ) - - if (extraCategories.length > 0) { - const extraNames = extraCategories.map(cat => this.data.categoryNames[cat]) - return showError(`不需要选择${extraNames.join('、')}`) - } - } else if (tabIndex === 1 && !selectedMealSetId) { + // 移除必选验证,公式只是推荐 + if (tabIndex === 1 && !selectedMealSetId) { return showError('请选择套餐') } + // 如果既没有选择菜品也没有选择套餐,提示用户 + const allDishIds = this.flattenSelectedDishes(selectedDishes) + if (tabIndex === 0 && allDishIds.length === 0) { + wx.showModal({ + title: '提示', + content: '您还没有选择任何菜品,确定要保存空的配餐计划吗?', + success: (res) => { + if (res.confirm) { + this.doSave() + } + } + }) + return + } + + this.doSave() + }, + + // 执行保存 + doSave() { + const { date, mealType, selectedDishes, selectedMealSetId, tabIndex } = this.data + showLoading('保存中...') const data = { date, meal_type: mealType, - dish_ids: tabIndex === 0 ? Object.values(selectedDishes) : undefined, + dish_ids: tabIndex === 0 ? this.flattenSelectedDishes(selectedDishes) : undefined, meal_set_id: tabIndex === 1 ? selectedMealSetId : undefined } - // 检查是否已存在计划 + // 检查是否已存在计划(需要同时按日期和餐别查询) get('/api/plans/plans/by_date/', { - date: this.data.date, - meal_type: this.data.mealType + date: this.data.date }) .then(res => { - if (res && res.length > 0) { + // 从结果中找到当前餐别的计划 + const existingPlan = res && res.length > 0 + ? res.find(plan => plan.meal_type === this.data.mealType) + : null + + if (existingPlan) { // 更新现有计划 - const plan = res[0] - return put(`/api/plans/plans/${plan.id}/`, data) + return put(`/api/plans/plans/${existingPlan.id}/`, data) } else { // 创建新计划 return post('/api/plans/plans/', data) @@ -324,37 +561,65 @@ Page({ const missing = requiredCategories.filter(cat => !selectedCategories.includes(cat)) const extra = selectedCategories.filter(cat => !requiredCategories.includes(cat)) + // 计算总选中的菜品数量 + const totalSelectedCount = this.flattenSelectedDishes(this.data.selectedDishes).length + return { isValid: missing.length === 0 && extra.length === 0, missing, extra, - score: Math.round((selectedCategories.length / requiredCategories.length) * 100) + score: Math.round((selectedCategories.length / requiredCategories.length) * 100), + totalSelectedCount } }, - // 快速选择推荐菜品 - quickSelectRecommended() { - const requiredCategories = this.getRequiredCategories() - const selectedDishes = {} + + // 从分类数据更新所有菜品列表 + updateAllDishesFromCategoryData(dishesByCategory) { + const allDishes = [] + const selectedDishIds = this.flattenSelectedDishes(this.data.selectedDishes) + + // 确保 dishesByCategory 是对象 + if (!dishesByCategory || typeof dishesByCategory !== 'object') { + this.setData({ allDishes: [] }) + return + } - requiredCategories.forEach(category => { - const dishes = this.getDishesByCategory(category) - if (dishes.length > 0) { - // 选择第一个菜品作为推荐 - selectedDishes[category] = dishes[0].id + Object.keys(dishesByCategory).forEach(category => { + const categoryData = dishesByCategory[category] + // 处理两种可能的格式: + // 1. categoryData 是一个对象,包含 dishes 数组 + // 2. categoryData 直接是 dishes 数组 + let dishes = [] + if (categoryData && Array.isArray(categoryData)) { + dishes = categoryData + } else if (categoryData && categoryData.dishes && Array.isArray(categoryData.dishes)) { + dishes = categoryData.dishes } + + dishes.forEach(dish => { + allDishes.push({ + ...dish, + isSelected: selectedDishIds.includes(dish.id) + }) + }) }) - this.setData({ selectedDishes }) - showSuccess('已选择推荐菜品') + this.setData({ allDishes }) }, - - // 清空选择 - clearSelection() { - this.setData({ - selectedDishes: {}, - selectedMealSetId: null + + // 下拉刷新 + onPullDownRefresh() { + // 重新加载数据 + this.loadBoundChefs() + this.loadData().finally(() => { + wx.stopPullDownRefresh() }) + + // 如果选择了厨神且在浏览模式,也重新加载所有菜品 + if (this.data.selectedChefId && this.data.viewMode === 'browse') { + this.loadAllDishes() + } } }) diff --git a/miniprogram/pages/gourmet/dish-selector/dish-selector.json b/miniprogram/pages/gourmet/dish-selector/dish-selector.json index 58a694c..0dd0417 100644 --- a/miniprogram/pages/gourmet/dish-selector/dish-selector.json +++ b/miniprogram/pages/gourmet/dish-selector/dish-selector.json @@ -1 +1,5 @@ -{"navigationBarTitleText": "选择菜品"} +{ + "navigationBarTitleText": "选择菜品", + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark" +} diff --git a/miniprogram/pages/gourmet/dish-selector/dish-selector.wxml b/miniprogram/pages/gourmet/dish-selector/dish-selector.wxml index a1511b4..b1041eb 100644 --- a/miniprogram/pages/gourmet/dish-selector/dish-selector.wxml +++ b/miniprogram/pages/gourmet/dish-selector/dish-selector.wxml @@ -11,9 +11,9 @@ {{nutritionTips[mealType]}} - + - 选择厨师 + 选择厨神 全部 - 全部厨师 + 全部厨神 - {{item.nickname || '厨师' + item.id}} + {{item.nickname || '厨神' + item.id}} @@ -71,60 +71,106 @@ - - 配餐公式 - - - {{categoryNames[category]}} - - - - - - - - 搭配进度 - {{Object.keys(selectedDishes).length}}/{{mealFormulas[mealType].length}} + + + + 配餐公式 - - 营养评分 - {{Math.round((Object.keys(selectedDishes).length / mealFormulas[mealType].length) * 100)}}分 + + 浏览所有 - - - - {{categoryNames[category]}} - - {{selectedDishes[category] ? '✓' : '必选'}} + + + + 配餐公式 + + + {{categoryNames[category]}} - - - - - - {{dish.name}} - {{dish.chef_name}} + + + + + + 搭配进度 + {{totalSelectedCount}}/{{mealFormulas[mealType].length}} + + + 营养评分 + {{Math.round((selectedCategoriesCount / mealFormulas[mealType].length) * 100)}}分 + + + + + + + {{categoryNames[category]}} + + {{selectedDishes[category] && selectedDishes[category].length > 0 ? '✓ ' + selectedDishes[category].length : '推荐'}} - - - 暂无{{categoryNames[category]}}菜品 + + + + + {{dish.name}} + {{dish.chef_name}} + + + + + + 暂无{{categoryNames[category]}}菜品 + + + + + + {{selectedChefName || '厨神'}}的菜品 + {{allDishes.length}}道菜品 + + + + + + + {{dish.name}} + + {{categoryNames[dish.category]}} + + + + + + + 该厨神暂无菜品 + + + @@ -155,10 +201,6 @@ - - - - diff --git a/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss b/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss index d8cf257..90da73c 100644 --- a/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss +++ b/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss @@ -315,8 +315,8 @@ color: #333; } -.required { - background: #ff4d4f; +.recommended { + background: #1890ff; border-radius: 10rpx; display: inline-flex; align-items: center; @@ -326,14 +326,14 @@ min-width: 44rpx; } -.required-text { +.recommended-text { font-size: 20rpx; color: white; line-height: 1; text-align: center; } -.required.completed { +.recommended.completed { background: #52c41a; } @@ -509,19 +509,6 @@ gap: 16rpx; } -.quick-actions { - display: flex; - gap: 12rpx; -} - -.quick-btn { - padding: 16rpx 24rpx; - font-size: 24rpx; - background: #f0f0f0; - color: #666; - border-radius: 8rpx; - border: none; -} .btn-primary { flex: 1; @@ -534,6 +521,130 @@ font-weight: bold; } +/* 浏览模式切换 */ +.view-mode-tabs { + display: flex; + background: #fff; + border-radius: 16rpx; + padding: 8rpx; + margin-bottom: 20rpx; + box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); +} + +.view-mode-tab { + flex: 1; + text-align: center; + padding: 16rpx; + font-size: 28rpx; + color: #666; + border-radius: 12rpx; + transition: all 0.3s; +} + +.view-mode-tab.active { + background: #1890ff; + color: #fff; + font-weight: bold; +} + +/* 浏览所有菜品样式 */ +.browse-all-dishes { + padding-bottom: 20rpx; +} + +.browse-header { + background: #fff; + border-radius: 16rpx; + padding: 24rpx; + margin-bottom: 20rpx; + box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); + display: flex; + justify-content: space-between; + align-items: center; +} + +.browse-title { + font-size: 32rpx; + font-weight: bold; + color: #333; +} + +.browse-count { + font-size: 24rpx; + color: #999; +} + +.all-dishes-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20rpx; +} + +.dish-card { + position: relative; + background: #fff; + border-radius: 16rpx; + overflow: hidden; + box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); + border: 2rpx solid transparent; + transition: all 0.3s; +} + +.dish-card.selected { + border-color: #1890ff; + background: #e6f7ff; +} + +.dish-card-image { + width: 100%; + height: 200rpx; +} + +.dish-card-info { + padding: 16rpx; +} + +.dish-card-name { + font-size: 28rpx; + font-weight: bold; + color: #333; + display: block; + margin-bottom: 8rpx; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dish-card-tags { + display: flex; + gap: 8rpx; + flex-wrap: wrap; +} + +.dish-card-tag { + font-size: 22rpx; + color: #1890ff; + background: #e6f7ff; + padding: 4rpx 10rpx; + border-radius: 8rpx; +} + +.dish-card-check { + position: absolute; + top: 12rpx; + right: 12rpx; + width: 40rpx; + height: 40rpx; + background: #1890ff; + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 24rpx; + font-weight: bold; +} + /* 加载状态 */ .loading { text-align: center; -- Gitee From e08c6078be00df7865af9c02d7ecc65a415338f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Mon, 3 Nov 2025 12:05:48 +0800 Subject: [PATCH 08/44] =?UTF-8?q?=E6=88=90=E5=8A=9F=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E9=A3=9F=E7=A5=9E=E9=85=8D=E9=A4=90=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/dishes/views.py | 2 +- .../gourmet/dish-selector/dish-selector.js | 67 +++++++++++++++---- miniprogram/pages/plan/plan.js | 66 ++++++++++++++++-- 3 files changed, 115 insertions(+), 20 deletions(-) diff --git a/backend/dishes/views.py b/backend/dishes/views.py index fc6ff21..f1518ed 100644 --- a/backend/dishes/views.py +++ b/backend/dishes/views.py @@ -467,7 +467,7 @@ def get_bound_gourmets(request): bindings = ChefGourmetBinding.objects.filter( chef=request.user, - status='approved' + status='accepted' ).select_related('gourmet') from users.serializers import UserSerializer diff --git a/miniprogram/pages/gourmet/dish-selector/dish-selector.js b/miniprogram/pages/gourmet/dish-selector/dish-selector.js index f0da481..cfac4f4 100644 --- a/miniprogram/pages/gourmet/dish-selector/dish-selector.js +++ b/miniprogram/pages/gourmet/dish-selector/dish-selector.js @@ -79,13 +79,14 @@ Page({ loadBoundChefs() { this.setData({ loadingChefs: true }) - get('/api/users/bindings/bound/') + return get('/api/users/bindings/bound/') .then(res => { console.log('已绑定厨师列表:', res) this.setData({ boundChefs: res.users || [], loadingChefs: false }) + return res }) .catch(err => { console.error('加载已绑定厨师失败:', err) @@ -93,6 +94,7 @@ Page({ boundChefs: [], loadingChefs: false }) + return Promise.reject(err) }) }, @@ -241,13 +243,15 @@ Page({ }, loadData() { - if (this.data.loading) return Promise.resolve() + if (this.data.loading) { + return Promise.resolve() + } this.setData({ loading: true }) console.log('开始加载菜品和套餐数据,选中厨师ID:', this.data.selectedChef) // 使用新的菜品选择器API - Promise.all([ + return Promise.all([ get('/api/plans/dish-selector/', { meal_type: this.data.mealType, chef_id: this.data.selectedChef, @@ -316,7 +320,9 @@ Page({ showError(dishesRes.error || '加载失败') } + // 加载已有计划(不等待完成) this.loadExistingPlan() + return Promise.resolve() }).catch(err => { console.error('数据加载异常:', err) @@ -377,7 +383,33 @@ Page({ } selectedDishes[dish.category].push(dish.id) }) - this.setData({ selectedDishes }) + + // 更新菜品列表中的选中状态 + const dishes = { ...this.data.dishes } + const selectedDishIds = [] + Object.values(selectedDishes).forEach(ids => { + selectedDishIds.push(...ids) + }) + + Object.keys(dishes).forEach(category => { + if (dishes[category] && dishes[category].dishes) { + dishes[category].dishes = dishes[category].dishes.map(dish => ({ + ...dish, + isSelected: selectedDishIds.includes(dish.id) + })) + } + }) + + this.setData({ + selectedDishes, + dishes + }, () => { + this.updateStatistics() + // 如果是在浏览模式,也要更新浏览列表 + if (this.data.viewMode === 'browse') { + this.updateAllDishesSelection() + } + }) } } }) @@ -610,16 +642,25 @@ Page({ // 下拉刷新 onPullDownRefresh() { - // 重新加载数据 - this.loadBoundChefs() - this.loadData().finally(() => { - wx.stopPullDownRefresh() + // 强制刷新,重置 loading 状态并立即执行加载 + this.setData({ loading: false }, () => { + // 确保所有方法都返回 Promise + const loadBoundChefsPromise = this.loadBoundChefs ? this.loadBoundChefs() : Promise.resolve() + const loadDataPromise = this.loadData ? this.loadData() : Promise.resolve() + + // 重新加载数据 + Promise.all([ + loadBoundChefsPromise.catch(() => Promise.resolve()), // 即使失败也继续 + loadDataPromise.catch(() => Promise.resolve()) // 即使失败也继续 + ]).finally(() => { + wx.stopPullDownRefresh() + + // 如果选择了厨神且在浏览模式,也重新加载所有菜品 + if (this.data.selectedChefId && this.data.viewMode === 'browse') { + this.loadAllDishes() + } + }) }) - - // 如果选择了厨神且在浏览模式,也重新加载所有菜品 - if (this.data.selectedChefId && this.data.viewMode === 'browse') { - this.loadAllDishes() - } } }) diff --git a/miniprogram/pages/plan/plan.js b/miniprogram/pages/plan/plan.js index f156804..a0f63f8 100644 --- a/miniprogram/pages/plan/plan.js +++ b/miniprogram/pages/plan/plan.js @@ -42,10 +42,8 @@ Page({ this.setData({ loading: true }) get('/api/chef/gourmets/') .then(res => { - const bindings = res.results || res - const gourmets = bindings - .filter(b => b.status === 'approved') - .map(b => b.gourmet) + // /api/chef/gourmets/ 返回的是食神用户数组(已过滤为accepted状态) + const gourmets = res.results || res || [] this.setData({ gourmets, @@ -71,14 +69,53 @@ Page({ const startDate = new Date().toISOString().split('T')[0] const endDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] - get('/api/chef/schedule/summary/', { + get('/api/gourmet/chef/gourmet-plans/', { gourmet_id: gourmetId, start_date: startDate, end_date: endDate }) .then(res => { + // 将后端返回的计划数据转换为前端期望的格式(按日期分组) + const plansData = res.plans || [] + const groupedByDate = {} + + plansData.forEach(plan => { + const date = plan.date + if (!groupedByDate[date]) { + groupedByDate[date] = { + date: date, + weekday: this.getWeekday(date), + meals: [] + } + } + + // 添加餐次信息 + const dishes = plan.dishes || [] + const dishNames = dishes.length > 0 + ? dishes.map(d => d.name).join('、') + : '无' + groupedByDate[date].meals.push({ + meal_type: plan.meal_type, + meal_type_name: plan.meal_type_display || this.getMealTypeName(plan.meal_type), + dish_names: dishNames + }) + }) + + // 转换为数组并按日期排序 + const plans = Object.values(groupedByDate).sort((a, b) => { + return new Date(a.date) - new Date(b.date) + }) + + // 对每天的餐次进行排序(早餐、午餐、晚餐) + plans.forEach(plan => { + plan.meals.sort((a, b) => { + const order = { breakfast: 1, lunch: 2, dinner: 3 } + return (order[a.meal_type] || 99) - (order[b.meal_type] || 99) + }) + }) + this.setData({ - plans: res.plans || [], + plans: plans, loading: false }) }) @@ -88,6 +125,23 @@ Page({ }) }, + // 获取星期几 + getWeekday(dateStr) { + const date = new Date(dateStr + 'T00:00:00') + const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] + return weekdays[date.getDay()] + }, + + // 获取餐别名称 + getMealTypeName(mealType) { + const names = { + breakfast: '早餐', + lunch: '午餐', + dinner: '晚餐' + } + return names[mealType] || mealType + }, + // 厨神:生成采购清单 generateShoppingList() { if (!this.data.selectedGourmet) { -- Gitee From c151f0fe1b235e246a25dd262a737e8733ca31e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Mon, 3 Nov 2025 13:44:11 +0800 Subject: [PATCH 09/44] =?UTF-8?q?=E5=8E=A8=E7=A5=9E=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/chef/gourmet-plans/gourmet-plans.js | 7 +- .../pages/chef/shopping-list/shopping-list.js | 237 ++++++++++++++++-- .../chef/shopping-list/shopping-list.wxml | 25 +- .../chef/shopping-list/shopping-list.wxss | 89 +++++-- miniprogram/pages/plan/plan.js | 226 ++++++++++++++++- miniprogram/pages/plan/plan.wxml | 35 ++- miniprogram/pages/plan/plan.wxss | 92 ++++++- 7 files changed, 662 insertions(+), 49 deletions(-) diff --git a/miniprogram/pages/chef/gourmet-plans/gourmet-plans.js b/miniprogram/pages/chef/gourmet-plans/gourmet-plans.js index 971a80d..616343b 100644 --- a/miniprogram/pages/chef/gourmet-plans/gourmet-plans.js +++ b/miniprogram/pages/chef/gourmet-plans/gourmet-plans.js @@ -25,7 +25,12 @@ Page({ this.setData({ gourmetId: parseInt(gourmet_id) }) } if (date) { - this.setData({ date }) + // 如果指定了单个日期,设置为同一天的开始和结束日期 + this.setData({ + date, + startDate: date, + endDate: date + }) } this.loadGourmetInfo() diff --git a/miniprogram/pages/chef/shopping-list/shopping-list.js b/miniprogram/pages/chef/shopping-list/shopping-list.js index 02ce0e5..986e1a8 100644 --- a/miniprogram/pages/chef/shopping-list/shopping-list.js +++ b/miniprogram/pages/chef/shopping-list/shopping-list.js @@ -1,6 +1,6 @@ // 采购清单 - 增强版 const { get } = require('../../../utils/request') -const { formatDate, showError, showLoading, hideLoading } = require('../../../utils/util') +const { formatDate, showError, showSuccess, showLoading, hideLoading } = require('../../../utils/util') Page({ data: { @@ -18,34 +18,81 @@ Page({ exportFormat: 'list' // 导出格式:list, categorized }, + onLoad(options) { + console.log('[ShoppingList] 页面加载', { + options, + timestamp: new Date().toISOString() + }) + + // 从URL参数中获取日期和食神ID + if (options.gourmetId) { + console.log('[ShoppingList] 从参数获取食神ID', { gourmetId: options.gourmetId }) + this.setData({ + selectedGourmetIds: [parseInt(options.gourmetId)], + selectAll: false + }) + } + if (options.startDate) { + console.log('[ShoppingList] 从参数获取开始日期', { startDate: options.startDate }) + this.setData({ startDate: options.startDate }) + } + if (options.endDate) { + console.log('[ShoppingList] 从参数获取结束日期', { endDate: options.endDate }) + this.setData({ endDate: options.endDate }) + } + }, + onShow() { this.loadGourmets() }, // 加载绑定的食神列表 loadGourmets() { - get('/api/chef/bindings/', { status: 'approved' }) + console.log('[ShoppingList] 开始加载食神列表', { timestamp: new Date().toISOString() }) + + get('/api/users/bindings/', { status: 'accepted' }) .then(res => { - const gourmets = (res.results || res || []).filter(binding => - binding.status === 'approved' - ).map(binding => ({ - id: binding.gourmet.id, - name: binding.gourmet.nickname || binding.gourmet.username, - avatar: binding.gourmet.avatar - })) + console.log('[ShoppingList] 加载食神列表成功', { + bindingsCount: (res.results || res || []).length, + timestamp: new Date().toISOString() + }) + const bindings = res.results || res || [] + const gourmets = bindings + .filter(binding => binding.status === 'accepted') + .map(binding => ({ + id: parseInt(binding.gourmet.id), // 确保 ID 为数字类型 + name: binding.gourmet.nickname || binding.gourmet.username || '食神' + binding.gourmet.id, + avatar: binding.gourmet.avatar_url || binding.gourmet.avatar + })) + + // 如果没有从URL参数传入选中的食神,则默认全选 + const selectedIds = this.data.selectedGourmetIds.length > 0 + ? this.data.selectedGourmetIds.filter(id => gourmets.some(g => g.id === id)) + : gourmets.map(g => g.id) this.setData({ gourmets, - selectedGourmetIds: gourmets.map(g => g.id), // 默认全选 - selectAll: true + selectedGourmetIds: selectedIds, + selectAll: selectedIds.length === gourmets.length && gourmets.length > 0 }) - if (gourmets.length > 0) { + // 如果有选中的食神,自动生成清单 + if (selectedIds.length > 0 && gourmets.length > 0) { + console.log('[ShoppingList] 自动生成采购清单', { + selectedIds, + gourmetsCount: gourmets.length, + timestamp: new Date().toISOString() + }) this.generate() } }) .catch(err => { - console.error('加载食神列表失败:', err) + console.error('[ShoppingList] 加载食神列表失败', { + error: err.message || err, + stack: err.stack, + timestamp: new Date().toISOString() + }) + showError(err.message || '加载食神列表失败') }) }, @@ -60,7 +107,7 @@ Page({ // 切换食神选择 toggleGourmet(e) { - const gourmetId = e.currentTarget.dataset.id + const gourmetId = parseInt(e.currentTarget.dataset.id) // 确保类型一致 let selectedIds = [...this.data.selectedGourmetIds] const index = selectedIds.indexOf(gourmetId) @@ -114,11 +161,24 @@ Page({ // 生成采购清单 generate() { + console.log('[ShoppingList] 开始生成采购清单', { + selectedGourmetIds: this.data.selectedGourmetIds, + startDate: this.data.startDate, + endDate: this.data.endDate, + gourmetCount: this.data.selectedGourmetIds.length, + timestamp: new Date().toISOString() + }) + if (this.data.selectedGourmetIds.length === 0) { + console.warn('[ShoppingList] 生成失败:未选择食神') return showError('请选择至少一位食神') } if (this.data.startDate > this.data.endDate) { + console.warn('[ShoppingList] 生成失败:日期范围无效', { + startDate: this.data.startDate, + endDate: this.data.endDate + }) return showError('开始日期不能晚于结束日期') } @@ -137,12 +197,30 @@ Page({ params.meal_types = this.data.mealTypes } - get('/api/chef/shopping-list/', params) + console.log('[ShoppingList] 请求参数', { + ...params, + timestamp: new Date().toISOString() + }) + + get('/api/gourmet/chef/shopping-list/', params) .then(res => { + console.log('[ShoppingList] 生成采购清单成功', { + shoppingListCount: res.shopping_list?.length || 0, + gourmetCount: res.stats?.gourmet_count || 0, + totalIngredients: res.stats?.total_ingredients || 0, + uniqueIngredients: res.stats?.unique_ingredients || 0, + timestamp: new Date().toISOString() + }) hideLoading() this.setData({ list: res, loading: false }) }) .catch(err => { + console.error('[ShoppingList] 生成采购清单失败', { + params, + error: err.message || err, + stack: err.stack, + timestamp: new Date().toISOString() + }) hideLoading() this.setData({ loading: false }) showError(err.message || '生成失败') @@ -199,10 +277,107 @@ Page({ return showError('暂无采购清单可分享') } + // 生成分享文本 + const shareText = this.generateShareText() + wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] }) + + // 提示用户可以通过右上角菜单分享 + wx.showToast({ + title: '请点击右上角分享', + icon: 'none', + duration: 2000 + }) + }, + + // 保存为图片 + saveAsImage() { + if (!this.data.list) { + return showError('暂无采购清单可保存') + } + + showLoading('生成图片中...') + + // 由于小程序限制,这里使用canvas绘制清单内容 + // 创建一个离屏canvas + const query = wx.createSelectorQuery() + query.select('.result-section').boundingClientRect() + query.exec((res) => { + if (!res || !res[0]) { + hideLoading() + showError('获取内容失败') + return + } + + // 生成文本内容用于分享 + const content = this.generateShareText() + + // 保存到剪贴板,让用户可以选择粘贴到其他地方 + wx.setClipboardData({ + data: content, + success: () => { + hideLoading() + wx.showModal({ + title: '保存成功', + content: '采购清单文本已复制到剪贴板,您可以在微信或其他应用中粘贴查看', + showCancel: false, + confirmText: '知道了' + }) + }, + fail: () => { + hideLoading() + showError('保存失败') + } + }) + }) + }, + + // 复制到剪贴板 + copyToClipboard() { + if (!this.data.list) { + return showError('暂无采购清单可复制') + } + + const content = this.generateShareText() + + wx.setClipboardData({ + data: content, + success: () => { + showSuccess('已复制到剪贴板') + }, + fail: () => { + showError('复制失败') + } + }) + }, + + // 生成分享文本 + generateShareText() { + const list = this.data.list + if (!list) return '' + + let text = `📋 采购清单\n\n` + text += `日期范围:${list.date_range || (this.data.startDate + ' 至 ' + this.data.endDate)}\n` + text += `食神数量:${this.data.selectedGourmetIds.length}位\n` + text += `生成时间:${list.generated_at || new Date().toLocaleString()}\n\n` + text += `━━━━━━━━━━━━━━━━━━━━\n\n` + text += `📦 食材清单:\n\n` + + if (list.shopping_list && list.shopping_list.length > 0) { + list.shopping_list.forEach((item, index) => { + text += `${index + 1}. ${item.name} - ${item.quantity} ${item.unit}\n` + }) + } else { + text += `暂无食材\n` + } + + text += `\n━━━━━━━━━━━━━━━━━━━━\n` + text += `来自:配膳官小程序` + + return text }, // 查看食材详情 @@ -250,6 +425,38 @@ Page({ } } }) + }, + + // 页面分享功能 + onShareAppMessage() { + if (!this.data.list) { + return { + title: '采购清单', + path: '/pages/chef/shopping-list/shopping-list' + } + } + + const shareText = this.generateShareText() + return { + title: `采购清单 - ${this.data.list.date_range || '配餐计划'}`, + path: '/pages/chef/shopping-list/shopping-list', + imageUrl: '' // 可以设置分享图片 + } + }, + + onShareTimeline() { + if (!this.data.list) { + return { + title: '采购清单', + query: '' + } + } + + return { + title: `采购清单 - ${this.data.list.date_range || '配餐计划'}`, + query: '', + imageUrl: '' // 可以设置分享图片 + } } }) diff --git a/miniprogram/pages/chef/shopping-list/shopping-list.wxml b/miniprogram/pages/chef/shopping-list/shopping-list.wxml index fb28c38..c1e837e 100644 --- a/miniprogram/pages/chef/shopping-list/shopping-list.wxml +++ b/miniprogram/pages/chef/shopping-list/shopping-list.wxml @@ -24,7 +24,9 @@ 选择食神 - + + + 全选 ({{selectedGourmetIds.length}}/{{gourmets.length}}) @@ -37,10 +39,11 @@ bindtap="toggleGourmet" data-id="{{item.id}}" > - + + + {{item.name}} - @@ -73,6 +76,22 @@ 该时间段内没有配餐计划 + + + + + + + diff --git a/miniprogram/pages/chef/shopping-list/shopping-list.wxss b/miniprogram/pages/chef/shopping-list/shopping-list.wxss index 1ba1df0..fc3caac 100644 --- a/miniprogram/pages/chef/shopping-list/shopping-list.wxss +++ b/miniprogram/pages/chef/shopping-list/shopping-list.wxss @@ -92,10 +92,42 @@ margin-left: 10rpx; } +.custom-checkbox { + width: 36rpx; + height: 36rpx; + border: 2rpx solid #d9d9d9; + border-radius: 6rpx; + display: flex; + align-items: center; + justify-content: center; + margin-right: 20rpx; + background: #fff; + transition: all 0.3s; +} + +.custom-checkbox.checked { + background: #1890ff; + border-color: #1890ff; +} + +.custom-checkbox text { + color: #fff; + font-size: 24rpx; + font-weight: bold; + line-height: 1; +} + .gourmet-list { display: flex; flex-direction: column; - gap: 15rpx; +} + +.gourmet-list .gourmet-item { + margin-bottom: 15rpx; +} + +.gourmet-list .gourmet-item:last-child { + margin-bottom: 0; } .gourmet-item { @@ -127,27 +159,6 @@ color: #333; } -.gourmet-check { - position: absolute; - top: -8rpx; - right: -8rpx; - width: 30rpx; - height: 30rpx; - background: #1890ff; - color: white; - border-radius: 50%; - font-size: 18rpx; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.3s; -} - -.gourmet-item.selected .gourmet-check { - opacity: 1; -} - .no-gourmets { color: #999; font-size: 26rpx; @@ -239,3 +250,37 @@ text-align: center; padding: 60rpx 40rpx; } + +.action-buttons { + display: flex; + padding: 30rpx; + border-top: 1rpx solid #f0f0f0; + background: #fafafa; +} + +.action-buttons .action-btn { + margin-right: 20rpx; +} + +.action-buttons .action-btn:last-child { + margin-right: 0; +} + +.action-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20rpx; + background: #fff; + border: 1rpx solid #e8e8e8; + border-radius: 12rpx; + font-size: 24rpx; + color: #333; +} + +.action-btn .icon { + font-size: 36rpx; + margin-bottom: 8rpx; +} diff --git a/miniprogram/pages/plan/plan.js b/miniprogram/pages/plan/plan.js index a0f63f8..0595f40 100644 --- a/miniprogram/pages/plan/plan.js +++ b/miniprogram/pages/plan/plan.js @@ -11,6 +11,9 @@ Page({ gourmets: [], selectedGourmet: null, plans: [], + plansData: [], // 保存原始计划数据(包含完整信息) + selectedStartDate: '', // 选择的开始日期 + selectedEndDate: '', // 选择的结束日期 // 食神数据:我的厨神列表 chefs: [], @@ -21,7 +24,14 @@ Page({ const app = getApp() const userInfo = app.globalData.userInfo + console.log('[Plan] 页面显示', { + userId: userInfo?.id, + role: userInfo?.role, + timestamp: new Date().toISOString() + }) + if (!userInfo || !userInfo.role) { + console.warn('[Plan] 用户信息缺失,跳转到角色选择页面') wx.redirectTo({ url: '/pages/role-select/role-select' }) @@ -31,26 +41,40 @@ Page({ this.setData({ role: userInfo.role }) if (userInfo.role === 'chef') { + console.log('[Plan] 厨神角色,加载食神列表') this.loadGourmets() } else if (userInfo.role === 'gourmet') { + console.log('[Plan] 食神角色,加载厨神列表') this.loadChefs() } }, // 厨神:加载绑定的食神列表 loadGourmets() { + console.log('[Plan] 开始加载绑定的食神列表', { timestamp: new Date().toISOString() }) this.setData({ loading: true }) get('/api/chef/gourmets/') .then(res => { // /api/chef/gourmets/ 返回的是食神用户数组(已过滤为accepted状态) const gourmets = res.results || res || [] + console.log('[Plan] 加载食神列表成功', { + gourmetsCount: gourmets.length, + gourmetIds: gourmets.map(g => g.id), + timestamp: new Date().toISOString() + }) + this.setData({ gourmets, loading: false }) }) .catch(err => { + console.error('[Plan] 加载食神列表失败', { + error: err.message || err, + stack: err.stack, + timestamp: new Date().toISOString() + }) this.setData({ loading: false }) showError(err.message || '加载失败') }) @@ -59,15 +83,33 @@ Page({ // 厨神:选择食神查看计划 selectGourmet(e) { const gourmetId = e.currentTarget.dataset.id + console.log('[Plan] 选择食神查看计划', { gourmetId, timestamp: new Date().toISOString() }) this.setData({ selectedGourmet: gourmetId }) this.loadGourmetPlans(gourmetId) }, // 厨神:加载食神的配餐计划 loadGourmetPlans(gourmetId) { + console.log('[Plan] 开始加载食神配餐计划', { + gourmetId, + startDate: this.data.selectedStartDate, + endDate: this.data.selectedEndDate, + timestamp: new Date().toISOString() + }) + this.setData({ loading: true }) - const startDate = new Date().toISOString().split('T')[0] - const endDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + const startDate = this.data.selectedStartDate || new Date().toISOString().split('T')[0] + const endDate = this.data.selectedEndDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + + // 如果没有设置日期范围,设置默认值 + if (!this.data.selectedStartDate) { + this.setData({ selectedStartDate: startDate }) + } + if (!this.data.selectedEndDate) { + this.setData({ selectedEndDate: endDate }) + } + + console.log('[Plan] 请求参数', { gourmet_id: gourmetId, start_date: startDate, end_date: endDate }) get('/api/gourmet/chef/gourmet-plans/', { gourmet_id: gourmetId, @@ -75,6 +117,11 @@ Page({ end_date: endDate }) .then(res => { + console.log('[Plan] 加载配餐计划成功', { + plansCount: res.plans?.length || 0, + hasStats: !!res.stats, + timestamp: new Date().toISOString() + }) // 将后端返回的计划数据转换为前端期望的格式(按日期分组) const plansData = res.plans || [] const groupedByDate = {} @@ -85,10 +132,14 @@ Page({ groupedByDate[date] = { date: date, weekday: this.getWeekday(date), - meals: [] + meals: [], + plans: [] // 保存该日期下的所有计划对象 } } + // 保存完整的计划对象 + groupedByDate[date].plans.push(plan) + // 添加餐次信息 const dishes = plan.dishes || [] const dishNames = dishes.length > 0 @@ -97,7 +148,9 @@ Page({ groupedByDate[date].meals.push({ meal_type: plan.meal_type, meal_type_name: plan.meal_type_display || this.getMealTypeName(plan.meal_type), - dish_names: dishNames + dish_names: dishNames, + plan_id: plan.id, + dishes: dishes // 保存菜品信息 }) }) @@ -114,12 +167,27 @@ Page({ }) }) + console.log('[Plan] 计划数据转换完成', { + groupedPlansCount: plans.length, + totalMeals: plans.reduce((sum, p) => sum + p.meals.length, 0), + timestamp: new Date().toISOString() + }) + this.setData({ plans: plans, + plansData: plansData, // 保存原始数据 loading: false }) }) .catch(err => { + console.error('[Plan] 加载配餐计划失败', { + gourmetId, + startDate, + endDate, + error: err.message || err, + stack: err.stack, + timestamp: new Date().toISOString() + }) this.setData({ loading: false }) showError(err.message || '加载失败') }) @@ -144,14 +212,162 @@ Page({ // 厨神:生成采购清单 generateShoppingList() { + console.log('[Plan] 点击生成采购清单', { + selectedGourmet: this.data.selectedGourmet, + selectedStartDate: this.data.selectedStartDate, + selectedEndDate: this.data.selectedEndDate, + timestamp: new Date().toISOString() + }) + if (!this.data.selectedGourmet) { + console.warn('[Plan] 生成采购清单失败:未选择食神') showError('请先选择食神') return } + // 如果有选择的日期范围,使用选择的日期;否则使用未来7天 + let startDate = this.data.selectedStartDate + let endDate = this.data.selectedEndDate + + if (!startDate) { + startDate = new Date().toISOString().split('T')[0] + } + if (!endDate) { + endDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + } + + console.log('[Plan] 跳转到采购清单页面', { + gourmetId: this.data.selectedGourmet, + startDate, + endDate, + timestamp: new Date().toISOString() + }) + wx.navigateTo({ - url: `/pages/chef/shopping-list/shopping-list?gourmetId=${this.data.selectedGourmet}` + url: `/pages/chef/shopping-list/shopping-list?gourmetId=${this.data.selectedGourmet}&startDate=${startDate}&endDate=${endDate}` + }) + }, + + // 查看单日计划详情 + viewDayPlan(e) { + const date = e.currentTarget.dataset.date + console.log('[Plan] 点击查看单日计划详情', { + date, + selectedGourmet: this.data.selectedGourmet, + timestamp: new Date().toISOString() + }) + + if (!this.data.selectedGourmet) { + console.warn('[Plan] 查看计划详情失败:未选择食神') + showError('请先选择食神') + return + } + + // 跳转到食神详细计划页面,并筛选到指定日期 + console.log('[Plan] 跳转到计划详情页面', { + gourmet_id: this.data.selectedGourmet, + date, + timestamp: new Date().toISOString() + }) + + wx.navigateTo({ + url: `/pages/chef/gourmet-plans/gourmet-plans?gourmet_id=${this.data.selectedGourmet}&date=${date}` + }) + }, + + // 选择开始日期 + selectStartDate(e) { + const startDate = e.detail.value + console.log('[Plan] 选择开始日期', { startDate, timestamp: new Date().toISOString() }) + this.setData({ + selectedStartDate: startDate + }) + }, + + // 选择结束日期 + selectEndDate(e) { + const endDate = e.detail.value + console.log('[Plan] 选择结束日期', { endDate, timestamp: new Date().toISOString() }) + this.setData({ + selectedEndDate: endDate + }) + }, + + // 重新加载指定日期范围的计划 + reloadPlansWithDateRange() { + if (!this.data.selectedGourmet) { + console.warn('[Plan] 刷新计划失败:未选择食神') + return + } + + const startDate = this.data.selectedStartDate || new Date().toISOString().split('T')[0] + const endDate = this.data.selectedEndDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + + console.log('[Plan] 刷新计划(按日期范围)', { + gourmetId: this.data.selectedGourmet, + startDate, + endDate, + timestamp: new Date().toISOString() }) + + this.setData({ loading: true }) + + get('/api/gourmet/chef/gourmet-plans/', { + gourmet_id: this.data.selectedGourmet, + start_date: startDate, + end_date: endDate + }) + .then(res => { + const plansData = res.plans || [] + const groupedByDate = {} + + plansData.forEach(plan => { + const date = plan.date + if (!groupedByDate[date]) { + groupedByDate[date] = { + date: date, + weekday: this.getWeekday(date), + meals: [], + plans: [] + } + } + + groupedByDate[date].plans.push(plan) + + const dishes = plan.dishes || [] + const dishNames = dishes.length > 0 + ? dishes.map(d => d.name).join('、') + : '无' + groupedByDate[date].meals.push({ + meal_type: plan.meal_type, + meal_type_name: plan.meal_type_display || this.getMealTypeName(plan.meal_type), + dish_names: dishNames, + plan_id: plan.id, + dishes: dishes + }) + }) + + const plans = Object.values(groupedByDate).sort((a, b) => { + return new Date(a.date) - new Date(b.date) + }) + + plans.forEach(plan => { + plan.meals.sort((a, b) => { + const order = { breakfast: 1, lunch: 2, dinner: 3 } + return (order[a.meal_type] || 99) - (order[b.meal_type] || 99) + }) + }) + + this.setData({ + plans: plans, + plansData: plansData, + loading: false + }) + }) + .catch(err => { + this.setData({ loading: false }) + showError(err.message || '加载失败') + }) }, // 厨神:进入食神管理页面 diff --git a/miniprogram/pages/plan/plan.wxml b/miniprogram/pages/plan/plan.wxml index 369f23c..d50e152 100644 --- a/miniprogram/pages/plan/plan.wxml +++ b/miniprogram/pages/plan/plan.wxml @@ -26,12 +26,42 @@ - 未来7天配餐 + 配餐计划 + + + + + + + + 开始日期 + {{selectedStartDate || '未选择'}} + + + + + + 结束日期 + {{selectedEndDate || '未选择'}} + + + + + + + + - + {{item.date}} {{item.weekday}} @@ -42,6 +72,7 @@ {{meal.dish_names}} + > diff --git a/miniprogram/pages/plan/plan.wxss b/miniprogram/pages/plan/plan.wxss index f3069de..cc14ab5 100644 --- a/miniprogram/pages/plan/plan.wxss +++ b/miniprogram/pages/plan/plan.wxss @@ -93,13 +93,36 @@ .plan-list { display: flex; flex-direction: column; - gap: 20rpx; +} + +.plan-list .plan-item { + margin-bottom: 20rpx; +} + +.plan-list .plan-item:last-child { + margin-bottom: 0; } .plan-item { + position: relative; border: 1rpx solid #f0f0f0; border-radius: 10rpx; padding: 20rpx; + background: #fafafa; + transition: all 0.3s; +} + +.plan-item:active { + background: #f0f0f0; +} + +.plan-arrow { + position: absolute; + right: 20rpx; + top: 50%; + transform: translateY(-50%); + font-size: 32rpx; + color: #999; } .plan-date { @@ -202,3 +225,70 @@ color: white; } +/* 日期范围选择 */ +.date-range-section { + margin-bottom: 20rpx; + padding: 20rpx; + background: #f8f9fa; + border-radius: 10rpx; +} + +.date-picker-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 15rpx; +} + +.date-picker-item { + flex: 1; + background: white; + padding: 20rpx; + border-radius: 8rpx; + text-align: center; +} + +.date-label { + font-size: 24rpx; + color: #999; + display: block; + margin-bottom: 8rpx; +} + +.date-value { + font-size: 28rpx; + color: #333; + font-weight: 500; +} + +.date-separator { + margin: 0 20rpx; + color: #666; + font-size: 28rpx; +} + +.btn-refresh { + width: 100%; + padding: 20rpx; + background: #1890ff; + color: white; + border-radius: 8rpx; + font-size: 28rpx; + border: none; +} + +.action-section { + margin-bottom: 20rpx; +} + +.action-btn { + width: 100%; + padding: 25rpx; + background: linear-gradient(135deg, #52c41a, #73d13d); + color: white; + border-radius: 10rpx; + font-size: 30rpx; + font-weight: bold; + border: none; +} + -- Gitee From b0daeee300d08bbd9aedda72c1433741ee5502b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Mon, 3 Nov 2025 13:46:24 +0800 Subject: [PATCH 10/44] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E9=87=87=E8=B4=AD=E6=B8=85=E5=8D=95=E9=A1=B5=E9=9D=A2=E3=80=82?= =?UTF-8?q?=E5=8B=BE=E9=80=89=E6=A1=86=E9=80=89=E6=8B=A9=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/chef/shopping-list/shopping-list.js | 25 +++++++++++++++++-- .../chef/shopping-list/shopping-list.wxml | 6 ++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/miniprogram/pages/chef/shopping-list/shopping-list.js b/miniprogram/pages/chef/shopping-list/shopping-list.js index 986e1a8..b5dee66 100644 --- a/miniprogram/pages/chef/shopping-list/shopping-list.js +++ b/miniprogram/pages/chef/shopping-list/shopping-list.js @@ -70,6 +70,11 @@ Page({ ? this.data.selectedGourmetIds.filter(id => gourmets.some(g => g.id === id)) : gourmets.map(g => g.id) + // 为每个 gourmet 添加 checked 属性 + gourmets.forEach(gourmet => { + gourmet.checked = selectedIds.includes(gourmet.id) + }) + this.setData({ gourmets, selectedGourmetIds: selectedIds, @@ -99,9 +104,18 @@ Page({ // 切换全选 toggleSelectAll() { const selectAll = !this.data.selectAll + const selectedIds = selectAll ? this.data.gourmets.map(g => g.id) : [] + + // 更新每个 gourmet 的 checked 状态 + const gourmets = this.data.gourmets.map(gourmet => ({ + ...gourmet, + checked: selectedIds.includes(gourmet.id) + })) + this.setData({ selectAll, - selectedGourmetIds: selectAll ? this.data.gourmets.map(g => g.id) : [] + selectedGourmetIds: selectedIds, + gourmets }) }, @@ -117,9 +131,16 @@ Page({ selectedIds.push(gourmetId) } + // 更新每个 gourmet 的 checked 状态 + const gourmets = this.data.gourmets.map(gourmet => ({ + ...gourmet, + checked: selectedIds.includes(gourmet.id) + })) + this.setData({ selectedGourmetIds: selectedIds, - selectAll: selectedIds.length === this.data.gourmets.length + selectAll: selectedIds.length === this.data.gourmets.length, + gourmets }) }, diff --git a/miniprogram/pages/chef/shopping-list/shopping-list.wxml b/miniprogram/pages/chef/shopping-list/shopping-list.wxml index c1e837e..3077ebe 100644 --- a/miniprogram/pages/chef/shopping-list/shopping-list.wxml +++ b/miniprogram/pages/chef/shopping-list/shopping-list.wxml @@ -33,14 +33,14 @@ - - + + {{item.name}} -- Gitee From 542d9fa74f01dc936bdc877bcdafdf221335585a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Mon, 3 Nov 2025 14:45:22 +0800 Subject: [PATCH 11/44] =?UTF-8?q?PDF=20=E7=94=9F=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/dishes/urls.py | 1 + backend/dishes/views.py | 46 ++ backend/meal_architect/utils/pdf_generator.py | 413 ++++++++++++++++++ backend/plans/urls.py | 1 + backend/plans/views.py | 113 ++++- deploy/install.sh | 25 ++ deploy/requirements.txt | 2 + miniprogram/app.js | 6 +- .../pages/chef/shopping-list/shopping-list.js | 199 ++++++++- .../chef/shopping-list/shopping-list.wxml | 7 + .../pages/gourmet/dish-detail/dish-detail.js | 108 ++++- 11 files changed, 889 insertions(+), 32 deletions(-) create mode 100644 backend/meal_architect/utils/pdf_generator.py diff --git a/backend/dishes/urls.py b/backend/dishes/urls.py index f7f56b6..b884f99 100644 --- a/backend/dishes/urls.py +++ b/backend/dishes/urls.py @@ -11,5 +11,6 @@ urlpatterns = [ path('gourmets/', views.get_bound_gourmets, name='get_bound_gourmets'), path('unit-choices/', views.get_unit_choices, name='get_unit_choices'), path('dishes//steps//', views.upload_step_image, name='upload_step_image'), + path('dishes//pdf/', views.generate_dish_pdf, name='generate_dish_pdf'), ] diff --git a/backend/dishes/views.py b/backend/dishes/views.py index f1518ed..c35de1d 100644 --- a/backend/dishes/views.py +++ b/backend/dishes/views.py @@ -476,3 +476,49 @@ def get_bound_gourmets(request): return Response(serializer.data) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def generate_dish_pdf(request, dish_id): + """生成菜品详情PDF""" + from django.http import HttpResponse + from meal_architect.utils.pdf_generator import generate_dish_detail_pdf + + try: + # 获取菜品信息 + dish = Dish.objects.prefetch_related('images', 'ingredients', 'cooking_steps').get(id=dish_id) + + # 权限检查:食神只能查看已绑定厨神的菜品 + if request.user.role == 'gourmet': + approved_chefs = ChefGourmetBinding.objects.filter( + gourmet=request.user, + status='accepted' + ).values_list('chef_id', flat=True) + + if dish.chef_id not in approved_chefs: + return Response({'error': '无权访问此菜品'}, status=status.HTTP_403_FORBIDDEN) + + # 序列化菜品数据 + serializer = DishSerializer(dish) + dish_data = serializer.data + + # 是否包含图片 + include_images = request.query_params.get('include_images', 'true').lower() == 'true' + + # 生成PDF + pdf_content = generate_dish_detail_pdf(dish_data, include_images=include_images) + + # 返回PDF文件 + response = HttpResponse(pdf_content, content_type='application/pdf') + filename = f"菜品详情_{dish.name}.pdf" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + except Dish.DoesNotExist: + return Response({'error': '菜品不存在'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"生成菜品PDF失败: {str(e)}") + return Response( + {'error': f'生成PDF失败: {str(e)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) diff --git a/backend/meal_architect/utils/pdf_generator.py b/backend/meal_architect/utils/pdf_generator.py new file mode 100644 index 0000000..00157a0 --- /dev/null +++ b/backend/meal_architect/utils/pdf_generator.py @@ -0,0 +1,413 @@ +""" +通用PDF生成工具类 +支持生成采购清单、菜品详情等内容的PDF文件 +""" +import os +from datetime import datetime +from io import BytesIO +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import cm +from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image, PageBreak +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT +from django.conf import settings + + +class PDFGenerator: + """PDF生成器基类""" + + def __init__(self): + self.buffer = BytesIO() + self.page_size = A4 + self.setup_fonts() + self.styles = self.create_styles() + + def setup_fonts(self): + """设置中文字体支持""" + import subprocess + + try: + # 优先使用 fc-list 查找字体(如果可用) + font_path = None + + # 尝试使用 fc-list 命令查找中文字体 + try: + # 查找文泉驿字体文件路径 + result = subprocess.run( + ['fc-list', ':lang=zh', 'file'], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + # fc-list 输出格式: /path/to/font: 字体名:样式 + for line in result.stdout.split('\n'): + # 提取文件路径(冒号前的部分) + if ':' in line: + path = line.split(':')[0].strip() + if 'wqy-zenhei' in path.lower() and os.path.exists(path): + font_path = path + break + elif line.strip() and os.path.exists(line.strip()): + # 有时输出就是纯路径 + if 'wqy-zenhei' in line.lower(): + font_path = line.strip() + break + except (subprocess.TimeoutExpired, FileNotFoundError, Exception): + pass + + # 如果 fc-list 没找到,尝试常见路径 + if not font_path: + font_paths = [ + '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc', + '/usr/share/fonts/wqy-zenhei/wqy-zenhei.ttc', + '/usr/share/fonts/opentype/wqy/wqy-zenhei.otf', + '/System/Library/Fonts/PingFang.ttc', # macOS + 'C:\\Windows\\Fonts\\simhei.ttf', # Windows + 'C:\\Windows\\Fonts\\msyh.ttc', # Windows 微软雅黑 + ] + + for path in font_paths: + if os.path.exists(path): + font_path = path + break + + # 注册字体 + if font_path: + try: + # 尝试注册为 TTFont + pdfmetrics.registerFont(TTFont('SimHei', font_path)) + self.chinese_font = 'SimHei' + print(f"成功加载中文字体: {font_path}") + return + except Exception as e: + print(f"注册字体失败 {font_path}: {e}") + # 继续尝试其他路径 + pass + + except Exception as e: + print(f"警告: 无法加载中文字体: {e}") + + # 如果所有方法都失败,使用默认字体(会显示为方块) + self.chinese_font = 'Helvetica' + print("警告: 未找到中文字体,PDF中的中文可能显示为方块。请安装中文字体包: apt install fonts-wqy-zenhei") + + def create_styles(self): + """创建样式""" + styles = getSampleStyleSheet() + + # 标题样式 + styles.add(ParagraphStyle( + name='ChineseTitle', + fontName=self.chinese_font, + fontSize=24, + textColor=colors.HexColor('#333333'), + alignment=TA_CENTER, + spaceAfter=20, + )) + + # 副标题样式 + styles.add(ParagraphStyle( + name='ChineseSubtitle', + fontName=self.chinese_font, + fontSize=16, + textColor=colors.HexColor('#666666'), + alignment=TA_CENTER, + spaceAfter=12, + )) + + # 正文样式 + styles.add(ParagraphStyle( + name='ChineseBody', + fontName=self.chinese_font, + fontSize=12, + textColor=colors.HexColor('#333333'), + alignment=TA_LEFT, + spaceAfter=6, + )) + + # 标注样式 + styles.add(ParagraphStyle( + name='ChineseCaption', + fontName=self.chinese_font, + fontSize=10, + textColor=colors.HexColor('#999999'), + alignment=TA_LEFT, + spaceAfter=6, + )) + + return styles + + def create_document(self, title="Document"): + """创建PDF文档""" + doc = SimpleDocTemplate( + self.buffer, + pagesize=self.page_size, + title=title, + author="配膳官", + leftMargin=2*cm, + rightMargin=2*cm, + topMargin=2*cm, + bottomMargin=2*cm, + ) + return doc + + def get_pdf_value(self): + """获取PDF内容""" + return self.buffer.getvalue() + + +class ShoppingListPDFGenerator(PDFGenerator): + """采购清单PDF生成器""" + + def generate(self, shopping_list_data): + """ + 生成采购清单PDF + + Args: + shopping_list_data: 采购清单数据 + { + 'date_range': '日期范围', + 'gourmet_count': 食神数量, + 'shopping_list': [{'name': '食材名', 'quantity': 数量, 'unit': '单位'}, ...], + 'generated_at': '生成时间' + } + """ + doc = self.create_document("采购清单") + story = [] + + # 标题 + title = Paragraph("📋 采购清单", self.styles['ChineseTitle']) + story.append(title) + story.append(Spacer(1, 0.5*cm)) + + # 基本信息 + info_data = [ + ['日期范围', shopping_list_data.get('date_range', '-')], + ['食神数量', f"{shopping_list_data.get('gourmet_count', 0)}位"], + ['生成时间', shopping_list_data.get('generated_at', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))], + ] + + info_table = Table(info_data, colWidths=[4*cm, 12*cm]) + info_table.setStyle(TableStyle([ + ('FONTNAME', (0, 0), (-1, -1), self.chinese_font), + ('FONTSIZE', (0, 0), (-1, -1), 11), + ('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#666666')), + ('TEXTCOLOR', (1, 0), (1, -1), colors.HexColor('#333333')), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ])) + story.append(info_table) + story.append(Spacer(1, 1*cm)) + + # 分隔线 + line = Table([['']], colWidths=[16*cm]) + line.setStyle(TableStyle([ + ('LINEABOVE', (0, 0), (-1, -1), 1, colors.HexColor('#E0E0E0')), + ])) + story.append(line) + story.append(Spacer(1, 0.5*cm)) + + # 食材清单标题 + subtitle = Paragraph("📦 食材清单", self.styles['ChineseSubtitle']) + story.append(subtitle) + story.append(Spacer(1, 0.5*cm)) + + # 食材列表 + shopping_list = shopping_list_data.get('shopping_list', []) + if shopping_list: + # 表头 + table_data = [['序号', '食材名称', '数量', '单位']] + + # 食材数据 + for idx, item in enumerate(shopping_list, 1): + table_data.append([ + str(idx), + item.get('name', '-'), + str(item.get('quantity', '-')), + item.get('unit', '-'), + ]) + + # 创建表格 + ingredient_table = Table(table_data, colWidths=[2*cm, 7*cm, 4*cm, 3*cm]) + ingredient_table.setStyle(TableStyle([ + ('FONTNAME', (0, 0), (-1, -1), self.chinese_font), + ('FONTSIZE', (0, 0), (-1, -1), 10), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4CAF50')), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#E0E0E0')), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#F5F5F5')]), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ])) + story.append(ingredient_table) + else: + no_data = Paragraph("暂无食材", self.styles['ChineseBody']) + story.append(no_data) + + story.append(Spacer(1, 1*cm)) + + # 底部信息 + footer = Paragraph( + "来自:配膳官小程序", + self.styles['ChineseCaption'] + ) + story.append(footer) + + # 构建PDF + doc.build(story) + return self.get_pdf_value() + + +class DishDetailPDFGenerator(PDFGenerator): + """菜品详情PDF生成器""" + + def generate(self, dish_data, include_images=True): + """ + 生成菜品详情PDF + + Args: + dish_data: 菜品数据 + include_images: 是否包含图片 + """ + doc = self.create_document(f"菜品详情 - {dish_data.get('name', '未命名')}") + story = [] + + # 标题 + title = Paragraph(f"🍽️ {dish_data.get('name', '未命名菜品')}", self.styles['ChineseTitle']) + story.append(title) + story.append(Spacer(1, 0.3*cm)) + + # 厨神信息 + chef = dish_data.get('chef', {}) + chef_name = chef.get('nickname') or chef.get('username', '未知厨神') + chef_info = Paragraph(f"厨神:{chef_name}", self.styles['ChineseSubtitle']) + story.append(chef_info) + story.append(Spacer(1, 0.5*cm)) + + # 基本信息 + info_list = [] + if dish_data.get('category'): + info_list.append(f"分类:{dish_data.get('category')}") + if dish_data.get('status'): + status_map = {'draft': '草稿', 'published': '已发布'} + info_list.append(f"状态:{status_map.get(dish_data.get('status'), dish_data.get('status'))}") + if dish_data.get('updated_at'): + info_list.append(f"更新时间:{dish_data.get('updated_at')}") + + if info_list: + for info in info_list: + info_p = Paragraph(info, self.styles['ChineseBody']) + story.append(info_p) + story.append(Spacer(1, 0.5*cm)) + + # 描述 + if dish_data.get('description'): + desc_title = Paragraph("📝 菜品描述", self.styles['ChineseSubtitle']) + story.append(desc_title) + story.append(Spacer(1, 0.3*cm)) + + desc = Paragraph(dish_data.get('description'), self.styles['ChineseBody']) + story.append(desc) + story.append(Spacer(1, 0.8*cm)) + + # 食材清单 + ingredients = dish_data.get('ingredients', []) + if ingredients: + ing_title = Paragraph("🥬 食材清单", self.styles['ChineseSubtitle']) + story.append(ing_title) + story.append(Spacer(1, 0.3*cm)) + + # 食材表格 + table_data = [['序号', '食材名称', '数量', '单位', '分类']] + for idx, ing in enumerate(ingredients, 1): + category_map = { + 'vegetable': '蔬菜', + 'protein': '蛋白质', + 'carb': '碳水', + 'fat': '脂肪' + } + table_data.append([ + str(idx), + ing.get('name', '-'), + str(ing.get('quantity', '-')), + ing.get('unit', '-'), + category_map.get(ing.get('category', ''), '-'), + ]) + + ing_table = Table(table_data, colWidths=[2*cm, 5*cm, 3*cm, 3*cm, 3*cm]) + ing_table.setStyle(TableStyle([ + ('FONTNAME', (0, 0), (-1, -1), self.chinese_font), + ('FONTSIZE', (0, 0), (-1, -1), 10), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#FF9800')), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#E0E0E0')), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#FFF8F0')]), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ])) + story.append(ing_table) + story.append(Spacer(1, 0.8*cm)) + + # 制作步骤 + cooking_steps = dish_data.get('cooking_steps', []) + if cooking_steps: + step_title = Paragraph("👨‍🍳 制作步骤", self.styles['ChineseSubtitle']) + story.append(step_title) + story.append(Spacer(1, 0.3*cm)) + + for step in cooking_steps: + step_num = f"步骤 {step.get('step_number', 1)}" + step_p = Paragraph(f"{step_num}", self.styles['ChineseBody']) + story.append(step_p) + + desc_p = Paragraph(step.get('description', ''), self.styles['ChineseBody']) + story.append(desc_p) + story.append(Spacer(1, 0.3*cm)) + + # 营养标签 + nutrition_tags = dish_data.get('nutrition_tags', []) + if nutrition_tags: + story.append(Spacer(1, 0.5*cm)) + nut_title = Paragraph("🏷️ 营养标签", self.styles['ChineseSubtitle']) + story.append(nut_title) + story.append(Spacer(1, 0.3*cm)) + + tags_text = "、".join(nutrition_tags) + tags_p = Paragraph(tags_text, self.styles['ChineseBody']) + story.append(tags_p) + + story.append(Spacer(1, 1*cm)) + + # 底部信息 + footer = Paragraph( + "来自:配膳官小程序", + self.styles['ChineseCaption'] + ) + story.append(footer) + + # 构建PDF + doc.build(story) + return self.get_pdf_value() + + +# 便捷函数 +def generate_shopping_list_pdf(shopping_list_data): + """生成采购清单PDF""" + generator = ShoppingListPDFGenerator() + return generator.generate(shopping_list_data) + + +def generate_dish_detail_pdf(dish_data, include_images=True): + """生成菜品详情PDF""" + generator = DishDetailPDFGenerator() + return generator.generate(dish_data, include_images) + diff --git a/backend/plans/urls.py b/backend/plans/urls.py index d5036f5..186a63a 100644 --- a/backend/plans/urls.py +++ b/backend/plans/urls.py @@ -16,6 +16,7 @@ urlpatterns = [ # 厨神相关API path('chef/schedule-summary/', views.chef_schedule_summary, name='chef_schedule_summary'), path('chef/shopping-list/', views.generate_shopping_list, name='generate_shopping_list'), + path('chef/shopping-list/pdf/', views.generate_shopping_list_pdf, name='generate_shopping_list_pdf'), path('chef/gourmet-plans/', views.chef_gourmet_plans, name='chef_gourmet_plans'), # ViewSet路由 - 放在最后 path('', include(router.urls)), diff --git a/backend/plans/views.py b/backend/plans/views.py index d07767e..68755d4 100644 --- a/backend/plans/views.py +++ b/backend/plans/views.py @@ -733,10 +733,121 @@ def generate_shopping_list(request): 'gourmet_info': gourmet_info, 'stats': stats, 'gourmet_breakdown': gourmet_breakdown, - 'generated_at': datetime.now().isoformat() + 'generated_at': datetime.now().isoformat(), + 'date_range': f"{start_date_str} 至 {end_date_str}" }) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def generate_shopping_list_pdf(request): + """生成采购清单PDF""" + if request.user.role != 'chef': + return Response({'error': '只有厨神可以生成采购清单PDF'}, status=status.HTTP_403_FORBIDDEN) + + from django.http import HttpResponse + from meal_architect.utils.pdf_generator import generate_shopping_list_pdf as gen_pdf + + # 获取参数 + start_date_str = request.query_params.get('start_date') + end_date_str = request.query_params.get('end_date') + gourmet_ids = request.query_params.getlist('gourmet_ids') + meal_types = request.query_params.getlist('meal_types') + + if not start_date_str or not end_date_str: + return Response( + {'error': '请提供start_date和end_date参数'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() + end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() + except ValueError: + return Response( + {'error': '日期格式错误,应为YYYY-MM-DD'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if start_date > end_date: + return Response( + {'error': '开始日期不能晚于结束日期'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 获取已绑定的食神 + approved_gourmets = ChefGourmetBinding.objects.filter( + chef=request.user, + status='accepted' + ).select_related('gourmet') + + if gourmet_ids: + gourmet_ids = [int(gid) for gid in gourmet_ids if gid.isdigit()] + approved_gourmets = approved_gourmets.filter(gourmet_id__in=gourmet_ids) + + gourmet_ids_list = list(approved_gourmets.values_list('gourmet_id', flat=True)) + + if not gourmet_ids_list: + return Response( + {'error': '您没有已绑定的食神'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 查询配餐计划 + queryset = GourmetDailyPlan.objects.filter( + gourmet_id__in=gourmet_ids_list, + date__range=[start_date, end_date] + ).prefetch_related('dishes__ingredients', 'gourmet') + + if meal_types: + queryset = queryset.filter(meal_type__in=meal_types) + + # 汇总食材 + ingredients_summary = defaultdict(lambda: defaultdict(float)) + + for plan in queryset: + for dish in plan.dishes.all(): + for ingredient in dish.ingredients.all(): + ingredients_summary[ingredient.name][ingredient.unit] += float(ingredient.quantity) + + # 生成采购清单 + shopping_list = [] + for name, units in ingredients_summary.items(): + for unit, quantity in units.items(): + shopping_list.append({ + 'name': name, + 'quantity': round(quantity, 2), + 'unit': unit + }) + + # 按名称排序 + shopping_list.sort(key=lambda x: x['name']) + + # 准备PDF数据 + pdf_data = { + 'date_range': f"{start_date_str} 至 {end_date_str}", + 'gourmet_count': len(gourmet_ids_list), + 'shopping_list': shopping_list, + 'generated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + + # 生成PDF + try: + pdf_content = gen_pdf(pdf_data) + + # 返回PDF文件 + response = HttpResponse(pdf_content, content_type='application/pdf') + filename = f"shopping_list_{start_date_str}_{end_date_str}.pdf" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + except Exception as e: + logger.error(f"生成采购清单PDF失败: {str(e)}") + return Response( + {'error': f'生成PDF失败: {str(e)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + @api_view(['GET']) @permission_classes([IsAuthenticated]) def search_chefs(request): diff --git a/deploy/install.sh b/deploy/install.sh index 9fd5825..19606e4 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -160,6 +160,28 @@ install_base_deps() { log_success "基础系统依赖安装完成" } +# 安装中文字体(用于PDF生成) +install_chinese_fonts() { + log_info "安装中文字体(用于PDF生成)..." + + wait_for_apt + + # 安装文泉驿字体包 + apt install -y fonts-wqy-zenhei fonts-wqy-microhei + check_result "chinese fonts installation" + + # 刷新字体缓存 + fc-cache -fv + + # 验证字体安装 + if fc-list :lang=zh | grep -q -i "wqy"; then + log_success "中文字体安装成功" + log_info "已安装字体: $(fc-list :lang=zh | grep -i wqy | head -2 | tr '\n' ' ')" + else + log_warning "中文字体安装可能失败,但继续执行" + fi +} + # 创建项目目录和虚拟环境 setup_project_env() { log_info "设置项目环境..." @@ -1097,6 +1119,9 @@ install_prod() { # 1. 安装系统依赖 install_base_deps + # 1.5. 安装中文字体(PDF生成需要) + install_chinese_fonts + # 2. 配置数据库 setup_database diff --git a/deploy/requirements.txt b/deploy/requirements.txt index cfe26c0..32fb3cc 100644 --- a/deploy/requirements.txt +++ b/deploy/requirements.txt @@ -9,5 +9,7 @@ requests==2.31.0 django-filter==23.3 drf-yasg==1.21.7 setuptools>=65.0.0 +reportlab==4.0.7 +pypdf==3.17.1 diff --git a/miniprogram/app.js b/miniprogram/app.js index f6cbf17..2fd84fc 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -29,7 +29,11 @@ App({ } // 生产环境 return 'https://b106.xyz' - })() + })(), + // apiUrl 别名,指向 baseUrl(为了兼容性) + get apiUrl() { + return this.baseUrl + } }, onLaunch() { diff --git a/miniprogram/pages/chef/shopping-list/shopping-list.js b/miniprogram/pages/chef/shopping-list/shopping-list.js index b5dee66..e870ebe 100644 --- a/miniprogram/pages/chef/shopping-list/shopping-list.js +++ b/miniprogram/pages/chef/shopping-list/shopping-list.js @@ -322,39 +322,192 @@ Page({ showLoading('生成图片中...') - // 由于小程序限制,这里使用canvas绘制清单内容 - // 创建一个离屏canvas - const query = wx.createSelectorQuery() - query.select('.result-section').boundingClientRect() - query.exec((res) => { - if (!res || !res[0]) { - hideLoading() - showError('获取内容失败') - return - } - - // 生成文本内容用于分享 - const content = this.generateShareText() + // 使用Canvas绘制采购清单 + const list = this.data.list + const ctx = wx.createCanvasContext('shoppingListCanvas') + + // 设置画布大小 + const canvasWidth = 750 + const canvasHeight = 1200 + let currentY = 40 + + // 绘制背景 + ctx.setFillStyle('#FFFFFF') + ctx.fillRect(0, 0, canvasWidth, canvasHeight) + + // 绘制标题 + ctx.setFillStyle('#333333') + ctx.setFontSize(32) + ctx.setTextAlign('center') + ctx.fillText('📋 采购清单', canvasWidth / 2, currentY) + currentY += 60 + + // 绘制基本信息 + ctx.setFillStyle('#666666') + ctx.setFontSize(24) + ctx.setTextAlign('left') + ctx.fillText(`日期范围:${list.date_range || (this.data.startDate + ' 至 ' + this.data.endDate)}`, 40, currentY) + currentY += 40 + ctx.fillText(`食神数量:${this.data.selectedGourmetIds.length}位`, 40, currentY) + currentY += 60 + + // 绘制分隔线 + ctx.setStrokeStyle('#E0E0E0') + ctx.setLineWidth(2) + ctx.beginPath() + ctx.moveTo(40, currentY) + ctx.lineTo(canvasWidth - 40, currentY) + ctx.stroke() + currentY += 40 + + // 绘制食材清单标题 + ctx.setFillStyle('#333333') + ctx.setFontSize(28) + ctx.fillText('📦 食材清单', 40, currentY) + currentY += 50 + + // 绘制食材列表 + if (list.shopping_list && list.shopping_list.length > 0) { + ctx.setFillStyle('#333333') + ctx.setFontSize(22) - // 保存到剪贴板,让用户可以选择粘贴到其他地方 - wx.setClipboardData({ - data: content, - success: () => { + list.shopping_list.forEach((item, index) => { + if (currentY > canvasHeight - 100) { + return // 防止超出画布 + } + ctx.fillText(`${index + 1}. ${item.name} - ${item.quantity} ${item.unit}`, 60, currentY) + currentY += 40 + }) + } else { + ctx.setFillStyle('#999999') + ctx.setFontSize(20) + ctx.fillText('暂无食材', 60, currentY) + currentY += 40 + } + + currentY += 40 + + // 绘制底部信息 + ctx.setFillStyle('#999999') + ctx.setFontSize(18) + ctx.setTextAlign('center') + ctx.fillText('来自:配膳官小程序', canvasWidth / 2, currentY) + + // 绘制完成 + ctx.draw(false, () => { + // 导出为图片 + wx.canvasToTempFilePath({ + canvasId: 'shoppingListCanvas', + success: (res) => { hideLoading() - wx.showModal({ - title: '保存成功', - content: '采购清单文本已复制到剪贴板,您可以在微信或其他应用中粘贴查看', - showCancel: false, - confirmText: '知道了' + // 保存到相册 + wx.saveImageToPhotosAlbum({ + filePath: res.tempFilePath, + success: () => { + showSuccess('图片已保存到相册') + }, + fail: (err) => { + if (err.errMsg.includes('auth deny')) { + wx.showModal({ + title: '需要授权', + content: '需要您授权保存图片到相册', + success: (modalRes) => { + if (modalRes.confirm) { + wx.openSetting() + } + } + }) + } else { + showError('保存失败') + } + } }) }, fail: () => { hideLoading() - showError('保存失败') + showError('生成图片失败') } }) }) }, + + // 保存为PDF + saveAsPDF() { + if (!this.data.list) { + return showError('暂无采购清单可保存') + } + + showLoading('生成PDF中...') + + const app = getApp() + // 获取API地址(注意:app.js中使用的是baseUrl) + const apiUrl = app.globalData.baseUrl || app.globalData.apiUrl || 'https://b106.xyz' + const token = wx.getStorageSync('token') + + console.log('[ShoppingList] API URL:', apiUrl) + console.log('[ShoppingList] Token存在:', !!token) + + if (!token) { + hideLoading() + return showError('请先登录') + } + + // 手动构建查询参数(小程序不支持URLSearchParams) + let params = `start_date=${this.data.startDate}&end_date=${this.data.endDate}` + + // 添加食神ID + this.data.selectedGourmetIds.forEach(id => { + params += `&gourmet_ids=${id}` + }) + + const url = `${apiUrl}/api/gourmet/chef/shopping-list/pdf/?${params}` + + console.log('[ShoppingList] 下载PDF URL:', url) + + // 下载PDF文件 + wx.downloadFile({ + url: url, + header: { + 'Authorization': `Bearer ${token}` + }, + success: (res) => { + console.log('[ShoppingList] PDF下载成功', res) + hideLoading() + if (res.statusCode === 200) { + // 打开文档 + wx.openDocument({ + filePath: res.tempFilePath, + fileType: 'pdf', + showMenu: true, + success: () => { + showSuccess('PDF已生成,可以通过右上角菜单保存或分享') + }, + fail: (err) => { + console.error('打开PDF失败', err) + showError('打开PDF失败') + } + }) + } else { + showError('生成PDF失败') + } + }, + fail: (err) => { + hideLoading() + console.error('[ShoppingList] 下载PDF失败', err) + + // 详细的错误提示 + if (err.errMsg && err.errMsg.includes('url scheme is invalid')) { + wx.showModal({ + title: 'PDF下载失败', + content: '请在小程序后台配置downloadFile合法域名,或在开发工具中勾选"不校验合法域名"选项。\n\n域名: ' + apiUrl, + showCancel: false + }) + } else { + showError('下载PDF失败: ' + (err.errMsg || '未知错误')) + } + } + }) + }, // 复制到剪贴板 copyToClipboard() { diff --git a/miniprogram/pages/chef/shopping-list/shopping-list.wxml b/miniprogram/pages/chef/shopping-list/shopping-list.wxml index 3077ebe..156d7ec 100644 --- a/miniprogram/pages/chef/shopping-list/shopping-list.wxml +++ b/miniprogram/pages/chef/shopping-list/shopping-list.wxml @@ -87,11 +87,18 @@ 💾 保存图片 + + + + diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.js b/miniprogram/pages/gourmet/dish-detail/dish-detail.js index 89455ff..69a9ece 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.js +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.js @@ -298,19 +298,113 @@ Page({ // 保存为PDF saveAsPDF() { this.hideShareMenu() + + if (!this.data.dish || !this.data.dishId) { + wx.showToast({ + title: '菜品信息不完整', + icon: 'none' + }) + return + } + wx.showLoading({ title: '生成PDF中...' }) - // TODO: 实现PDF生成逻辑 - // 这里需要将页面内容转换为PDF - // 可以使用canvas + html2canvas类似的逻辑 + const app = getApp() + // 获取API地址(注意:app.js中使用的是baseUrl) + const apiUrl = app.globalData.baseUrl || app.globalData.apiUrl || 'https://b106.xyz' + const token = wx.getStorageSync('token') - setTimeout(() => { + if (!token) { wx.hideLoading() wx.showToast({ - title: 'PDF保存成功', - icon: 'success' + title: '请先登录', + icon: 'none' }) - }, 2000) + return + } + + // 询问是否包含图片 + wx.showModal({ + title: '生成选项', + content: '是否在PDF中包含图片?(包含图片文件会更大)', + confirmText: '包含图片', + cancelText: '仅文字', + success: (modalRes) => { + const includeImages = modalRes.confirm ? 'true' : 'false' + const url = `${apiUrl}/api/dishes/dishes/${this.data.dishId}/pdf/?include_images=${includeImages}` + + // 下载PDF文件 + wx.downloadFile({ + url: url, + header: { + 'Authorization': `Bearer ${token}` + }, + success: (res) => { + wx.hideLoading() + if (res.statusCode === 200) { + // 保存文件路径供后续使用 + const tempFilePath = res.tempFilePath + + // 打开文档 + wx.openDocument({ + filePath: tempFilePath, + fileType: 'pdf', + showMenu: true, + success: () => { + wx.showToast({ + title: 'PDF已打开,可通过右上角菜单保存或分享', + icon: 'none', + duration: 3000 + }) + }, + fail: (err) => { + console.error('打开PDF失败', err) + + // 如果打开失败,尝试保存到本地 + const fs = wx.getFileSystemManager() + const fileName = `菜品详情_${this.data.dish.name}_${Date.now()}.pdf` + const savedPath = `${wx.env.USER_DATA_PATH}/${fileName}` + + fs.saveFile({ + tempFilePath: tempFilePath, + filePath: savedPath, + success: () => { + wx.showModal({ + title: 'PDF已保存', + content: `文件已保存到本地:${savedPath}`, + showCancel: false + }) + }, + fail: () => { + wx.showToast({ + title: '保存PDF失败', + icon: 'none' + }) + } + }) + } + }) + } else { + wx.showToast({ + title: '生成PDF失败', + icon: 'none' + }) + } + }, + fail: (err) => { + wx.hideLoading() + console.error('下载PDF失败', err) + wx.showToast({ + title: '下载PDF失败', + icon: 'none' + }) + } + }) + }, + fail: () => { + wx.hideLoading() + } + }) }, /** -- Gitee From 8db7faaa5741f563602e91c788a1ec31e0104f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Mon, 3 Nov 2025 14:58:49 +0800 Subject: [PATCH 12/44] =?UTF-8?q?PDF=20=E7=94=9F=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=AE=8C=E7=BE=8E=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/meal_architect/utils/pdf_generator.py | 95 +++++++++++++++++-- .../pages/gourmet/dish-detail/dish-detail.js | 5 + .../gourmet/dish-detail/dish-detail.wxml | 2 +- 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/backend/meal_architect/utils/pdf_generator.py b/backend/meal_architect/utils/pdf_generator.py index 00157a0..6daeb71 100644 --- a/backend/meal_architect/utils/pdf_generator.py +++ b/backend/meal_architect/utils/pdf_generator.py @@ -3,6 +3,8 @@ 支持生成采购清单、菜品详情等内容的PDF文件 """ import os +import tempfile +import requests from datetime import datetime from io import BytesIO from reportlab.lib import colors @@ -180,7 +182,7 @@ class ShoppingListPDFGenerator(PDFGenerator): story = [] # 标题 - title = Paragraph("📋 采购清单", self.styles['ChineseTitle']) + title = Paragraph("采购清单", self.styles['ChineseTitle']) story.append(title) story.append(Spacer(1, 0.5*cm)) @@ -213,7 +215,7 @@ class ShoppingListPDFGenerator(PDFGenerator): story.append(Spacer(1, 0.5*cm)) # 食材清单标题 - subtitle = Paragraph("📦 食材清单", self.styles['ChineseSubtitle']) + subtitle = Paragraph("食材清单", self.styles['ChineseSubtitle']) story.append(subtitle) story.append(Spacer(1, 0.5*cm)) @@ -268,6 +270,55 @@ class ShoppingListPDFGenerator(PDFGenerator): class DishDetailPDFGenerator(PDFGenerator): """菜品详情PDF生成器""" + def __init__(self): + super().__init__() + self.temp_files = [] # 保存临时文件路径,在PDF构建完成后删除 + + def _download_image(self, image_url, max_size=(14*cm, 10*cm)): + """ + 下载图片并返回 Image 对象 + + Args: + image_url: 图片URL + max_size: 最大尺寸 (width, height) + + Returns: + Image 对象或 None(如果下载失败) + """ + try: + # 下载图片 + response = requests.get(image_url, timeout=10, stream=True) + if response.status_code != 200: + print(f"下载图片失败: {image_url}, 状态码: {response.status_code}") + return None + + # 保存到临时文件 + with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_file: + for chunk in response.iter_content(chunk_size=8192): + tmp_file.write(chunk) + tmp_path = tmp_file.name + + # 保存临时文件路径,用于后续清理 + self.temp_files.append(tmp_path) + + # 创建 Image 对象(reportlab会在构建时读取文件) + img = Image(tmp_path, width=max_size[0], height=max_size[1], kind='proportional') + + return img + except Exception as e: + print(f"处理图片失败 {image_url}: {str(e)}") + return None + + def _cleanup_temp_files(self): + """清理临时文件""" + for tmp_path in self.temp_files: + try: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + except Exception as e: + print(f"删除临时文件失败 {tmp_path}: {str(e)}") + self.temp_files = [] + def generate(self, dish_data, include_images=True): """ 生成菜品详情PDF @@ -280,7 +331,7 @@ class DishDetailPDFGenerator(PDFGenerator): story = [] # 标题 - title = Paragraph(f"🍽️ {dish_data.get('name', '未命名菜品')}", self.styles['ChineseTitle']) + title = Paragraph(dish_data.get('name', '未命名菜品'), self.styles['ChineseTitle']) story.append(title) story.append(Spacer(1, 0.3*cm)) @@ -307,9 +358,27 @@ class DishDetailPDFGenerator(PDFGenerator): story.append(info_p) story.append(Spacer(1, 0.5*cm)) + # 菜品图片 + if include_images: + images = dish_data.get('images', []) + if images: + img_title = Paragraph("菜品图片", self.styles['ChineseSubtitle']) + story.append(img_title) + story.append(Spacer(1, 0.3*cm)) + + for img_data in images: + image_url = img_data.get('image_url') + if image_url: + img = self._download_image(image_url, max_size=(14*cm, 10*cm)) + if img: + story.append(img) + story.append(Spacer(1, 0.3*cm)) + + story.append(Spacer(1, 0.5*cm)) + # 描述 if dish_data.get('description'): - desc_title = Paragraph("📝 菜品描述", self.styles['ChineseSubtitle']) + desc_title = Paragraph("菜品描述", self.styles['ChineseSubtitle']) story.append(desc_title) story.append(Spacer(1, 0.3*cm)) @@ -320,7 +389,7 @@ class DishDetailPDFGenerator(PDFGenerator): # 食材清单 ingredients = dish_data.get('ingredients', []) if ingredients: - ing_title = Paragraph("🥬 食材清单", self.styles['ChineseSubtitle']) + ing_title = Paragraph("食材清单", self.styles['ChineseSubtitle']) story.append(ing_title) story.append(Spacer(1, 0.3*cm)) @@ -360,7 +429,7 @@ class DishDetailPDFGenerator(PDFGenerator): # 制作步骤 cooking_steps = dish_data.get('cooking_steps', []) if cooking_steps: - step_title = Paragraph("👨‍🍳 制作步骤", self.styles['ChineseSubtitle']) + step_title = Paragraph("制作步骤", self.styles['ChineseSubtitle']) story.append(step_title) story.append(Spacer(1, 0.3*cm)) @@ -371,13 +440,21 @@ class DishDetailPDFGenerator(PDFGenerator): desc_p = Paragraph(step.get('description', ''), self.styles['ChineseBody']) story.append(desc_p) + + # 步骤图片 + if include_images and step.get('image_url'): + story.append(Spacer(1, 0.2*cm)) + img = self._download_image(step.get('image_url'), max_size=(12*cm, 8*cm)) + if img: + story.append(img) + story.append(Spacer(1, 0.3*cm)) # 营养标签 nutrition_tags = dish_data.get('nutrition_tags', []) if nutrition_tags: story.append(Spacer(1, 0.5*cm)) - nut_title = Paragraph("🏷️ 营养标签", self.styles['ChineseSubtitle']) + nut_title = Paragraph("营养标签", self.styles['ChineseSubtitle']) story.append(nut_title) story.append(Spacer(1, 0.3*cm)) @@ -396,6 +473,10 @@ class DishDetailPDFGenerator(PDFGenerator): # 构建PDF doc.build(story) + + # 清理临时文件 + self._cleanup_temp_files() + return self.get_pdf_value() diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.js b/miniprogram/pages/gourmet/dish-detail/dish-detail.js index 69a9ece..0eab359 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.js +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.js @@ -284,6 +284,11 @@ Page({ this.setData({ showShareMenu: false }) }, + // 阻止事件冒泡(用于分享菜单) + stopPropagation() { + // 空方法,仅用于阻止事件冒泡 + }, + // 分享到微信 shareToWechat() { this.hideShareMenu() diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml index 8d79785..54b60db 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml @@ -148,7 +148,7 @@ - - - 选择菜品 - 选择套餐 - - - - - - 分类 - {{selectedCategoryLabel || '全部分类'}} - - - - + 🔍 - - - - - - - - 配餐公式 - - - 浏览所有 - - - - - - - 配餐公式 - - - {{categoryNames[category]}} - - - - - - - - 搭配进度 - {{totalSelectedCount}}/{{mealFormulas[mealType].length}} - - - 营养评分 - {{Math.round((selectedCategoriesCount / mealFormulas[mealType].length) * 100)}}分 - - - - - - - {{categoryNames[category]}} - - {{selectedDishes[category] && selectedDishes[category].length > 0 ? '✓ ' + selectedDishes[category].length : '推荐'}} - - - - - - - - {{dish.name}} - {{dish.chef_name}} - - - - - - 暂无{{categoryNames[category]}}菜品 - - - - - - - - - {{selectedChefName || '厨神'}}的菜品 - {{allDishes.length}}道菜品 - - - - - - - {{dish.name}} - - {{categoryNames[dish.category]}} - - - - - - - 该厨神暂无菜品 - + + + + {{item.label}} - - - - - - {{item.name}} - - {{item.nutrition_status && item.nutrition_status.is_valid ? '✓' : '✗'}} - + + + + + + {{dish.name}} + + {{dish.chef_name}} + {{categoryNames[dish.category]}} - {{item.dishes.length}}道菜 - by {{item.chef_name}} - - - - 暂无{{mealType === 'breakfast' ? '早餐' : mealType === 'lunch' ? '午餐' : '晚餐'}}套餐 + + + + + 暂无菜品 diff --git a/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss b/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss index 90da73c..870edd1 100644 --- a/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss +++ b/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss @@ -14,12 +14,16 @@ box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } -.title { - font-size: 48rpx; +.header-row { + display: flex; + align-items: center; + gap: 16rpx; +} + +.title-small { + font-size: 32rpx; font-weight: bold; color: #333; - display: block; - margin-bottom: 8rpx; } .date { @@ -132,34 +136,6 @@ font-weight: bold; } -/* 标签页样式 */ -.tabs { - display: flex; - background: #fff; - margin-bottom: 20rpx; - border-radius: 16rpx; - overflow: hidden; -} - -.tab { - flex: 1; - text-align: center; - padding: 30rpx; - font-size: 30rpx; - color: #666; - transition: all 0.3s; -} - -.tab.active { - color: #1890ff; - background: #e6f7ff; - font-weight: bold; -} - -.tab.disabled { - opacity: 0.5; - pointer-events: none; -} /* 筛选条件 */ .filters { @@ -170,38 +146,13 @@ box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } -.filter-group { - display: flex; - gap: 20rpx; - margin-bottom: 16rpx; -} - -.filter-item { - flex: 1; - display: flex; - justify-content: space-between; - align-items: center; - padding: 16rpx 20rpx; - background: #fafafa; - border-radius: 8rpx; -} - -.filter-label { - font-size: 26rpx; - color: #666; -} - -.filter-value { - font-size: 26rpx; - color: #333; -} - .search-box { display: flex; align-items: center; background: #fafafa; border-radius: 8rpx; padding: 16rpx 20rpx; + margin-bottom: 16rpx; } .search-input { @@ -216,285 +167,126 @@ margin-left: 12rpx; } -/* 配餐公式头部 */ -.formula-header { - background: linear-gradient(135deg, #1890ff, #40a9ff); - color: white; - padding: 30rpx; - margin-bottom: 20rpx; - border-radius: 16rpx; -} - -.formula-title { - font-size: 32rpx; - font-weight: bold; - display: block; - margin-bottom: 20rpx; -} - -.formula-tags { +.category-buttons { display: flex; flex-wrap: wrap; - gap: 15rpx; -} - -.formula-tag { - background: rgba(255, 255, 255, 0.2); - border-radius: 16rpx; - backdrop-filter: blur(10rpx); - display: inline-flex; - align-items: center; - justify-content: center; - height: 40rpx; - padding: 0 16rpx; - min-width: 60rpx; -} - -.formula-tag-text { - font-size: 24rpx; - color: white; - line-height: 1; - text-align: center; -} - -/* 营养搭配状态 */ -.nutrition-status { - background: #fff; - border-radius: 16rpx; - padding: 20rpx; - margin-bottom: 20rpx; - display: flex; - justify-content: space-around; - box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); -} - -.status-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 8rpx; + gap: 12rpx; } -.status-label { - font-size: 24rpx; +.category-btn { + padding: 12rpx 24rpx; + background: #fafafa; + border-radius: 20rpx; + border: 2rpx solid #e9ecef; + font-size: 26rpx; color: #666; + transition: all 0.3s; } -.status-value { - font-size: 32rpx; - font-weight: bold; - color: #1890ff; -} - -/* 菜品分类样式 */ -.dish-categories { - display: flex; - flex-direction: column; - gap: 20rpx; -} - -.category-section { - background: #fff; - border-radius: 16rpx; - padding: 20rpx; - box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); -} - -.category-title { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20rpx; - padding-bottom: 15rpx; - border-bottom: 2rpx solid #f0f0f0; -} - -.category-name { - font-size: 32rpx; - font-weight: bold; - color: #333; -} - -.recommended { +.category-btn.active { background: #1890ff; - border-radius: 10rpx; - display: inline-flex; - align-items: center; - justify-content: center; - height: 32rpx; - padding: 0 10rpx; - min-width: 44rpx; + border-color: #1890ff; + color: #fff; } -.recommended-text { - font-size: 20rpx; - color: white; - line-height: 1; - text-align: center; +.category-btn text { + font-size: 26rpx; } -.recommended.completed { - background: #52c41a; -} -.category-dishes { +/* 菜品列表样式 */ +.dish-list { display: flex; - flex-wrap: wrap; + flex-direction: column; gap: 16rpx; } -.dish-option { +.dish-card { position: relative; - background: #fafafa; - border: 2rpx solid #e9ecef; - border-radius: 12rpx; - padding: 16rpx; - width: calc(50% - 8rpx); + background: #fff; + border-radius: 16rpx; + overflow: hidden; + box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); + border: 2rpx solid transparent; + transition: all 0.3s; display: flex; align-items: center; - gap: 12rpx; - transition: all 0.3s; + padding: 20rpx; + gap: 20rpx; } -.dish-option.selected { - background: #e6f7ff; +.dish-card.selected { border-color: #1890ff; - color: #1890ff; + background: #e6f7ff; } -.dish-image { - width: 60rpx; - height: 60rpx; - border-radius: 8rpx; +.dish-card-image { + width: 120rpx; + height: 120rpx; + border-radius: 12rpx; + flex-shrink: 0; } -.dish-info { +.dish-card-info { flex: 1; display: flex; flex-direction: column; - gap: 4rpx; + gap: 8rpx; } -.dish-name { - font-size: 26rpx; - font-weight: 500; +.dish-card-name { + font-size: 30rpx; + font-weight: bold; color: #333; + display: block; } -.dish-chef { - font-size: 22rpx; - color: #666; -} - -.dish-check { - position: absolute; - top: -8rpx; - right: -8rpx; - width: 24rpx; - height: 24rpx; - background: #1890ff; - color: white; - border-radius: 50%; - font-size: 16rpx; +.dish-card-meta { display: flex; align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.3s; -} - -.dish-option.selected .dish-check { - opacity: 1; -} - -.no-dishes { - color: #999; - font-size: 26rpx; - text-align: center; - padding: 40rpx; - background: #f8f9fa; - border-radius: 12rpx; - border: 2rpx dashed #ddd; - width: 100%; -} - -/* 套餐列表样式 */ -.meal-set-list { - display: flex; - flex-direction: column; gap: 16rpx; } -.meal-set-card { - background: #fff; - border-radius: 16rpx; - padding: 24rpx; - box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); - border: 2rpx solid transparent; - transition: all 0.3s; +.dish-card-chef { + font-size: 24rpx; + color: #666; } -.meal-set-card.selected { - border-color: #1890ff; +.dish-card-category { + font-size: 24rpx; + color: #1890ff; background: #e6f7ff; + padding: 4rpx 12rpx; + border-radius: 8rpx; } -.meal-set-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12rpx; -} - -.meal-set-name { - font-size: 32rpx; - font-weight: bold; - color: #333; -} - -.nutrition-badge { - width: 32rpx; - height: 32rpx; +.dish-card-check { + position: absolute; + top: 12rpx; + right: 12rpx; + width: 40rpx; + height: 40rpx; + background: #1890ff; + color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; - font-size: 20rpx; + font-size: 24rpx; font-weight: bold; } -.nutrition-badge.valid { - background: #f6ffed; - color: #52c41a; -} - -.nutrition-badge.invalid { - background: #fff2f0; - color: #ff4d4f; -} - -.meal-set-dishes { - font-size: 26rpx; - color: #666; - margin-bottom: 8rpx; - display: block; -} - -.meal-set-chef { - font-size: 24rpx; +.no-dishes { color: #999; -} - -.no-meal-sets { + font-size: 26rpx; text-align: center; padding: 60rpx; - color: #999; - font-size: 28rpx; background: #fff; border-radius: 16rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } + /* 底部操作栏 */ .bottom-bar { position: fixed; @@ -521,129 +313,6 @@ font-weight: bold; } -/* 浏览模式切换 */ -.view-mode-tabs { - display: flex; - background: #fff; - border-radius: 16rpx; - padding: 8rpx; - margin-bottom: 20rpx; - box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); -} - -.view-mode-tab { - flex: 1; - text-align: center; - padding: 16rpx; - font-size: 28rpx; - color: #666; - border-radius: 12rpx; - transition: all 0.3s; -} - -.view-mode-tab.active { - background: #1890ff; - color: #fff; - font-weight: bold; -} - -/* 浏览所有菜品样式 */ -.browse-all-dishes { - padding-bottom: 20rpx; -} - -.browse-header { - background: #fff; - border-radius: 16rpx; - padding: 24rpx; - margin-bottom: 20rpx; - box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); - display: flex; - justify-content: space-between; - align-items: center; -} - -.browse-title { - font-size: 32rpx; - font-weight: bold; - color: #333; -} - -.browse-count { - font-size: 24rpx; - color: #999; -} - -.all-dishes-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 20rpx; -} - -.dish-card { - position: relative; - background: #fff; - border-radius: 16rpx; - overflow: hidden; - box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); - border: 2rpx solid transparent; - transition: all 0.3s; -} - -.dish-card.selected { - border-color: #1890ff; - background: #e6f7ff; -} - -.dish-card-image { - width: 100%; - height: 200rpx; -} - -.dish-card-info { - padding: 16rpx; -} - -.dish-card-name { - font-size: 28rpx; - font-weight: bold; - color: #333; - display: block; - margin-bottom: 8rpx; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dish-card-tags { - display: flex; - gap: 8rpx; - flex-wrap: wrap; -} - -.dish-card-tag { - font-size: 22rpx; - color: #1890ff; - background: #e6f7ff; - padding: 4rpx 10rpx; - border-radius: 8rpx; -} - -.dish-card-check { - position: absolute; - top: 12rpx; - right: 12rpx; - width: 40rpx; - height: 40rpx; - background: #1890ff; - color: white; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 24rpx; - font-weight: bold; -} /* 加载状态 */ .loading { -- Gitee From 5719140c5a2d7d8288d5407d1824948adb42d5b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Mon, 3 Nov 2025 16:53:03 +0800 Subject: [PATCH 14/44] =?UTF-8?q?=E7=AE=80=E5=8C=96=E9=A3=9F=E7=A5=9E?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/gourmet/daily-plan/daily-plan.js | 23 +++- .../pages/gourmet/daily-plan/daily-plan.wxml | 16 ++- .../pages/gourmet/daily-plan/daily-plan.wxss | 61 ++++----- .../gourmet/dish-selector/dish-selector.js | 116 ++++++++++++++---- 4 files changed, 146 insertions(+), 70 deletions(-) diff --git a/miniprogram/pages/gourmet/daily-plan/daily-plan.js b/miniprogram/pages/gourmet/daily-plan/daily-plan.js index 8753467..58fe189 100644 --- a/miniprogram/pages/gourmet/daily-plan/daily-plan.js +++ b/miniprogram/pages/gourmet/daily-plan/daily-plan.js @@ -282,6 +282,10 @@ Page({ const planId = e.currentTarget.dataset.planId const mealName = this.data.mealTypeNames[mealType] + if (!planId) { + return showError('配餐计划ID不存在') + } + wx.showModal({ title: '确认删除', content: `确定要从${mealName}中删除这个菜品吗?`, @@ -292,17 +296,29 @@ Page({ // 获取当前计划 get(`/api/plans/plans/${planId}/`) .then(plan => { + if (!plan || !plan.dishes) { + throw new Error('获取配餐计划失败') + } + // 从菜品列表中移除 const dishIds = plan.dishes .map(d => d.id) .filter(id => id !== dishId) - // 更新计划 - return put(`/api/plans/plans/${planId}/`, { + // 构建更新数据 + const updateData = { date: plan.date, meal_type: plan.meal_type, dish_ids: dishIds - }) + } + + // 如果计划有套餐,删除菜品时需要清除套餐关联 + if (plan.meal_set) { + updateData.meal_set_id = null + } + + // 更新计划 + return put(`/api/plans/plans/${planId}/`, updateData) }) .then(() => { hideLoading() @@ -312,6 +328,7 @@ Page({ }) .catch(err => { hideLoading() + console.error('删除失败:', err) showError(err.message || '删除失败') }) } diff --git a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml index 09158ca..a2d9670 100644 --- a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml +++ b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml @@ -6,12 +6,6 @@ {{date}} - - - - - - @@ -20,7 +14,7 @@ {{mealTypeNames[item]}} - > + @@ -42,7 +36,6 @@ - 修改 删除 @@ -50,7 +43,6 @@ - 未设置{{mealTypeNames[item]}} @@ -109,6 +101,12 @@ + + + + + + 加载中... diff --git a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss index 627d743..fdab298 100644 --- a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss +++ b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss @@ -18,44 +18,46 @@ .header-left { display: flex; - flex-direction: column; - gap: 8rpx; + flex-direction: row; + align-items: center; + gap: 16rpx; flex: 1; } .title { - font-size: 36rpx; + font-size: 28rpx; font-weight: bold; color: #333; } .date-text { - font-size: 24rpx; + font-size: 28rpx; color: #999; } -.actions-row { +/* 底部操作按钮 */ +.actions-row-bottom { display: flex; - gap: 12rpx; - background: #fff; - border-radius: 16rpx; - padding: 20rpx 30rpx; - margin-bottom: 20rpx; - box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); + gap: 16rpx; + padding: 30rpx 20rpx; + margin-top: 20rpx; + justify-content: center; } -.action-btn { - padding: 12rpx 20rpx; - font-size: 24rpx; - background: #1890ff; - color: #fff; +.action-btn-bottom { + padding: 12rpx 24rpx; + font-size: 26rpx; + background: #f5f5f5; + color: #999; border-radius: 8rpx; - border: none; + border: 1rpx solid #e8e8e8; line-height: 1.2; } -.action-btn.danger { - background: #ff4d4f; +.action-btn-bottom.danger { + background: #f5f5f5; + color: #999; + border: 1rpx solid #e8e8e8; } /* 营养汇总样式(底部) */ @@ -162,8 +164,9 @@ } .meal-arrow { - font-size: 32rpx; + font-size: 40rpx; color: #999; + line-height: 1; } .meal-info { @@ -173,7 +176,7 @@ } .meal-name { - font-size: 36rpx; + font-size: 28rpx; font-weight: bold; color: #333; } @@ -278,10 +281,6 @@ line-height: 1.2; } -.edit-btn { - background: #1890ff; -} - .delete-btn { background: #ff4d4f; } @@ -322,15 +321,9 @@ /* 空状态样式 */ .empty-state { - text-align: center; - padding: 40rpx; - color: #8c8c8c; -} - -.empty-text { - font-size: 28rpx; - margin-bottom: 20rpx; - display: block; + /* 空状态不显示文字 */ + min-height: 0; + padding: 0; } .btn-primary { diff --git a/miniprogram/pages/gourmet/dish-selector/dish-selector.js b/miniprogram/pages/gourmet/dish-selector/dish-selector.js index d39390b..2e4b0ff 100644 --- a/miniprogram/pages/gourmet/dish-selector/dish-selector.js +++ b/miniprogram/pages/gourmet/dish-selector/dish-selector.js @@ -52,8 +52,16 @@ Page({ this.loadCategories() // 然后加载已绑定厨师列表 this.loadBoundChefs() - // 最后加载菜品数据 - this.loadData() + // 先加载已有配餐计划,然后再加载菜品数据 + this.loadExistingPlan() + .then(() => { + // 已有计划加载完成后再加载菜品数据 + this.loadData() + }) + .catch(() => { + // 即使加载已有计划失败,也继续加载菜品数据 + this.loadData() + }) }, // 更新配餐公式文本 @@ -229,21 +237,25 @@ Page({ // 根据选中的分类筛选菜品 const filteredDishes = this.filterDishesByCategories(allDishes) + // 获取已选中的菜品ID列表 + const selectedDishIds = this.flattenSelectedDishes(this.data.selectedDishes) + + // 将已选中的菜品排在最前面 + const sortedDishes = this.sortDishesBySelection(filteredDishes, selectedDishIds) + console.log('处理后的菜品数据:', { totalDishes: allDishes.length, selectedCategories: this.data.selectedCategories, - filteredDishes: filteredDishes.length + filteredDishes: sortedDishes.length, + selectedCount: selectedDishIds.length }) this.setData({ allDishes: allDishes, - displayDishes: filteredDishes, + displayDishes: sortedDishes, loading: false }) - // 加载已有计划(不等待完成) - this.loadExistingPlan() - return Promise.resolve() }).catch(err => { console.error('菜品数据加载失败:', err) @@ -255,11 +267,23 @@ Page({ // 根据选中的分类筛选菜品 filterDishesByCategories(dishes) { - // 如果没有选择任何分类,不显示菜品 + // 获取已选中的菜品ID列表 + const selectedDishIds = this.flattenSelectedDishes(this.data.selectedDishes) + + // 如果没有选择任何分类,只显示已选中的菜品 if (!this.data.selectedCategories || this.data.selectedCategories.length === 0) { - return [] + return dishes.filter(dish => selectedDishIds.includes(dish.id)) } - return dishes.filter(dish => this.data.selectedCategories.includes(dish.category)) + + // 筛选:符合分类条件的菜品 + 已选中的菜品(即使不符合分类条件也要显示) + const filteredDishes = dishes.filter(dish => { + const matchesCategory = this.data.selectedCategories.includes(dish.category) + const isSelected = selectedDishIds.includes(dish.id) + // 如果符合分类条件,或者已选中,都显示 + return matchesCategory || isSelected + }) + + return filteredDishes }, // 检查菜品是否已选中 @@ -271,7 +295,7 @@ Page({ // 加载已有的配餐计划 loadExistingPlan() { - get('/api/plans/plans/by_date/', { + return get('/api/plans/plans/by_date/', { date: this.data.date, meal_type: this.data.mealType }) @@ -287,21 +311,32 @@ Page({ selectedDishes[dish.category].push(dish.id) }) - // 更新显示菜品列表中的选中状态 - const displayDishes = this.data.displayDishes.map(dish => { - const isSelected = this.isDishSelected(dish.id) - return { ...dish, isSelected } - }) - this.setData({ - selectedDishes, - displayDishes + selectedDishes }) + + // 如果菜品数据已经加载,更新显示列表 + if (this.data.displayDishes && this.data.displayDishes.length > 0) { + const selectedDishIds = this.flattenSelectedDishes(selectedDishes) + const displayDishes = this.data.displayDishes.map(dish => { + const isSelected = selectedDishIds.includes(dish.id) + return { ...dish, isSelected } + }) + + // 重新排序:已选中的排在最前面 + const sortedDishes = this.sortDishesBySelection(displayDishes, selectedDishIds) + + this.setData({ + displayDishes: sortedDishes + }) + } } } + return Promise.resolve() }) .catch(err => { console.error('加载现有计划失败:', err) + return Promise.resolve() // 即使失败也继续执行 }) }, @@ -340,12 +375,40 @@ Page({ filterDisplayDishes() { // 直接从allDishes中筛选,不需要重新请求 const filteredDishes = this.filterDishesByCategories(this.data.allDishes) + + // 获取已选中的菜品ID列表 + const selectedDishIds = this.flattenSelectedDishes(this.data.selectedDishes) + + // 将已选中的菜品排在最前面 + const sortedDishes = this.sortDishesBySelection(filteredDishes, selectedDishIds) + console.log('筛选菜品:', { allDishesCount: this.data.allDishes.length, selectedCategories: this.data.selectedCategories, - filteredCount: filteredDishes.length + filteredCount: sortedDishes.length, + selectedCount: selectedDishIds.length + }) + this.setData({ displayDishes: sortedDishes }) + }, + + // 将已选中的菜品排在最前面 + sortDishesBySelection(dishes, selectedDishIds) { + const selectedDishes = [] + const unselectedDishes = [] + + dishes.forEach(dish => { + const isSelected = selectedDishIds.includes(dish.id) + const dishWithSelection = { ...dish, isSelected } + + if (isSelected) { + selectedDishes.push(dishWithSelection) + } else { + unselectedDishes.push(dishWithSelection) + } }) - this.setData({ displayDishes: filteredDishes }) + + // 已选中的排在最前面 + return [...selectedDishes, ...unselectedDishes] }, // 选择菜品 @@ -424,14 +487,19 @@ Page({ selectedDishes[finalCategory].push(finalDishId) } - // 更新显示菜品列表中的选中状态 + // 更新显示菜品列表中的选中状态,并重新排序 + const updatedDishIds = this.flattenSelectedDishes(selectedDishes) const displayDishes = this.data.displayDishes.map(dish => { if (dish.id === finalDishId) { return { ...dish, isSelected: !isSelected } } - return dish + // 更新所有菜品的选中状态 + return { ...dish, isSelected: updatedDishIds.includes(dish.id) } }) + // 重新排序:已选中的排在最前面 + const sortedDishes = this.sortDishesBySelection(displayDishes, updatedDishIds) + console.log('更新后的选择状态:', { selectedDishes, dishId: finalDishId, @@ -441,7 +509,7 @@ Page({ this.setData({ selectedDishes, - displayDishes + displayDishes: sortedDishes }) }, -- Gitee From a70d9be8ec0d7d82ae3b222f5c9738efb51fdebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Mon, 3 Nov 2025 17:00:44 +0800 Subject: [PATCH 15/44] =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/gourmet/daily-plan/daily-plan.wxml | 3 - .../pages/gourmet/daily-plan/daily-plan.wxss | 2 +- .../gourmet/dish-selector/dish-selector.js | 61 ++++++++++--------- 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml index a2d9670..bd78dcb 100644 --- a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml +++ b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxml @@ -35,9 +35,6 @@ {{dish.category_display}} - - 删除 - diff --git a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss index fdab298..2d69ae0 100644 --- a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss +++ b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss @@ -81,7 +81,7 @@ } .summary-title { - font-size: 32rpx; + font-size: 28rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; diff --git a/miniprogram/pages/gourmet/dish-selector/dish-selector.js b/miniprogram/pages/gourmet/dish-selector/dish-selector.js index 2e4b0ff..d0b1231 100644 --- a/miniprogram/pages/gourmet/dish-selector/dish-selector.js +++ b/miniprogram/pages/gourmet/dish-selector/dish-selector.js @@ -296,41 +296,46 @@ Page({ // 加载已有的配餐计划 loadExistingPlan() { return get('/api/plans/plans/by_date/', { - date: this.data.date, - meal_type: this.data.mealType + date: this.data.date }) .then(res => { - if (res && res.length > 0) { - const plan = res[0] - if (plan.dishes && plan.dishes.length > 0) { - const selectedDishes = {} - plan.dishes.forEach(dish => { - if (!selectedDishes[dish.category]) { - selectedDishes[dish.category] = [] - } - selectedDishes[dish.category].push(dish.id) + // 从返回的计划数组中,找到当前餐次的计划 + const currentMealType = this.data.mealType + const plan = res && res.length > 0 + ? res.find(p => p.meal_type === currentMealType) + : null + + if (plan && plan.dishes && plan.dishes.length > 0) { + const selectedDishes = {} + plan.dishes.forEach(dish => { + if (!selectedDishes[dish.category]) { + selectedDishes[dish.category] = [] + } + selectedDishes[dish.category].push(dish.id) + }) + + this.setData({ + selectedDishes + }) + + // 如果菜品数据已经加载,更新显示列表 + if (this.data.displayDishes && this.data.displayDishes.length > 0) { + const selectedDishIds = this.flattenSelectedDishes(selectedDishes) + const displayDishes = this.data.displayDishes.map(dish => { + const isSelected = selectedDishIds.includes(dish.id) + return { ...dish, isSelected } }) + // 重新排序:已选中的排在最前面 + const sortedDishes = this.sortDishesBySelection(displayDishes, selectedDishIds) + this.setData({ - selectedDishes + displayDishes: sortedDishes }) - - // 如果菜品数据已经加载,更新显示列表 - if (this.data.displayDishes && this.data.displayDishes.length > 0) { - const selectedDishIds = this.flattenSelectedDishes(selectedDishes) - const displayDishes = this.data.displayDishes.map(dish => { - const isSelected = selectedDishIds.includes(dish.id) - return { ...dish, isSelected } - }) - - // 重新排序:已选中的排在最前面 - const sortedDishes = this.sortDishesBySelection(displayDishes, selectedDishIds) - - this.setData({ - displayDishes: sortedDishes - }) - } } + } else { + // 如果没有找到当前餐次的计划,清空selectedDishes + this.setData({ selectedDishes: {} }) } return Promise.resolve() }) -- Gitee From 84a666b57b8fbf82ca7f6a00c32fad86e30aef70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Mon, 3 Nov 2025 17:36:36 +0800 Subject: [PATCH 16/44] test --- .../pages/gourmet/dish-detail/dish-detail.js | 98 ++-- .../gourmet/dish-detail/dish-detail.wxml | 40 +- .../gourmet/dish-detail/dish-detail.wxss | 133 +++++- .../gourmet/dish-selector/dish-selector.js | 54 ++- ...71\346\241\210\350\256\276\350\256\241.md" | 355 +++++++++++++++ ...04\344\274\260\346\226\271\346\241\210.md" | 419 ++++++++++++++++++ 6 files changed, 1067 insertions(+), 32 deletions(-) create mode 100644 "\345\210\206\347\261\273\346\226\271\346\241\210\350\256\276\350\256\241.md" create mode 100644 "\346\224\271\345\212\250\350\257\204\344\274\260\346\226\271\346\241\210.md" diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.js b/miniprogram/pages/gourmet/dish-detail/dish-detail.js index 0eab359..706a583 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.js +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.js @@ -14,6 +14,16 @@ Page({ showShareMenu: false, // 是否显示分享菜单 user: null, // 当前用户信息 + // 添加配餐相关 + showAddMealModal: false, + selectedDate: '', + selectedMealTypeIndex: -1, + mealTypeList: [ + { value: 'breakfast', label: '早餐' }, + { value: 'lunch', label: '午餐' }, + { value: 'dinner', label: '晚餐' } + ], + categoryNames: { 'vegetable': '蔬菜', 'protein': '蛋白质', @@ -153,35 +163,67 @@ Page({ }) }, - // 选择这个菜品进行配餐 - selectForMeal() { - // 检查是否从菜品选择器页面跳转过来 - const pages = getCurrentPages() - let targetPage = null + // 显示添加配餐弹窗 + showAddMealModal() { + const { formatDate } = require('../../../utils/util') + const today = formatDate(new Date()) + this.setData({ + showAddMealModal: true, + selectedDate: today, + selectedMealTypeIndex: 0 // 默认选择早餐 + }) + }, + + // 隐藏添加配餐弹窗 + hideAddMealModal() { + this.setData({ + showAddMealModal: false + }) + }, + + // 日期选择变化 + onDateChange(e) { + this.setData({ + selectedDate: e.detail.value + }) + }, + + // 餐次选择变化 + onMealTypeChange(e) { + this.setData({ + selectedMealTypeIndex: parseInt(e.detail.value) + }) + }, + + // 确认添加配餐 + confirmAddMeal() { + const { selectedDate, selectedMealTypeIndex, mealTypeList, dishId } = this.data - // 查找菜品选择器页面 - for (let i = pages.length - 2; i >= 0; i--) { - if (pages[i].route.includes('dish-selector')) { - targetPage = pages[i] - break - } + if (!selectedDate) { + wx.showToast({ + title: '请选择日期', + icon: 'none' + }) + return } - if (targetPage) { - // 回到菜品选择器并选择这个菜品 - const dish = this.data.dish - if (targetPage.selectDishFromDetail) { - targetPage.selectDishFromDetail(dish) - } - - // 返回到菜品选择器页面 - wx.navigateBack({ - delta: pages.length - 1 - pages.indexOf(targetPage) + if (selectedMealTypeIndex < 0 || selectedMealTypeIndex >= mealTypeList.length) { + wx.showToast({ + title: '请选择餐次', + icon: 'none' }) - } else { - // 直接返回上一页 - wx.navigateBack() + return } + + const mealType = mealTypeList[selectedMealTypeIndex].value + + // 跳转到菜品选择器页面,并传递参数 + wx.navigateTo({ + url: `/pages/gourmet/dish-selector/dish-selector?date=${selectedDate}&mealType=${mealType}&dishId=${dishId}` + }) + + // 关闭弹窗 + this.hideAddMealModal() }, // 编辑菜品 @@ -292,8 +334,14 @@ Page({ // 分享到微信 shareToWechat() { this.hideShareMenu() + // 显示分享菜单(微信原生分享) + wx.showShareMenu({ + withShareTicket: true, + menus: ['shareAppMessage', 'shareTimeline'] + }) + // 提示用户使用右上角分享 wx.showToast({ - title: '请点击右上角分享到微信', + title: '请点击右上角分享按钮', icon: 'none', duration: 2000 }) diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml index 54b60db..ad64e30 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml @@ -140,9 +140,9 @@ @@ -155,17 +155,49 @@ + + + + + 添加配餐 + + + + + 选择日期 + + + {{selectedDate || '请选择日期'}} + + + + + 选择餐次 + + + {{selectedMealTypeIndex >= 0 ? mealTypeList[selectedMealTypeIndex].label : '请选择餐次'}} + + + + + + + + + + + + + + + @@ -88,7 +79,9 @@ 制作步骤 - + + + + @@ -122,7 +115,9 @@ - + + + diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.wxss b/miniprogram/pages/chef/dish-edit/dish-edit.wxss index f4a38ea..a33d1b1 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.wxss +++ b/miniprogram/pages/chef/dish-edit/dish-edit.wxss @@ -64,15 +64,33 @@ margin-bottom: 20rpx; } -.add-ingredient-btn { - padding: 0 20rpx; +/* 添加按钮(右侧+号) */ +.add-btn { + width: 50rpx; height: 50rpx; - line-height: 50rpx; - font-size: 24rpx; + min-width: 50rpx; + min-height: 50rpx; background-color: #1890ff; color: #fff; - border: none; - border-radius: 4rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0; + margin: 0; + box-sizing: border-box; +} + +.add-btn-text { + font-size: 40rpx; + font-weight: normal; + line-height: 1; + display: block; + margin: 0; + padding: 0; + color: #fff; + text-align: center; } .ingredient-list { @@ -119,16 +137,52 @@ font-size: 28rpx; } +/* 底部操作栏 */ +.bottom-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #fff; + border-top: 1rpx solid #f0f0f0; + padding: 20rpx 30rpx; + display: flex; + gap: 20rpx; + z-index: 100; + box-shadow: 0 -2rpx 12rpx rgba(0,0,0,0.1); +} + .save-btn { - width: 100%; + flex: 1; height: 80rpx; line-height: 80rpx; font-size: 32rpx; + font-weight: bold; + border-radius: 25rpx; + border: none; display: flex; align-items: center; justify-content: center; } +.btn-primary { + background: linear-gradient(135deg, #1890ff, #40a9ff); + color: white; +} + +.btn-secondary { + background: #f0f0f0; + color: #666; +} + +.btn-secondary:active { + background: #e0e0e0; +} + +.btn-primary:active { + transform: scale(0.98); +} + /* 错误文本样式 */ .error-text { color: #ff4d4f; @@ -288,15 +342,31 @@ color: #333; } -/* 添加步骤按钮 */ -.add-step-btn { - padding: 0 20rpx; - height: 50rpx; - line-height: 50rpx; - font-size: 24rpx; - background-color: #52c41a; +/* 营养成分分类按钮样式 */ +.nutrition-categories-list { + display: flex; + flex-wrap: wrap; + gap: 15rpx; + margin-top: 20rpx; +} + +.nutrition-category-btn { + padding: 15rpx 30rpx; + background-color: #f5f5f5; + border: 2rpx solid #d9d9d9; + border-radius: 25rpx; + font-size: 26rpx; + color: #333; + transition: all 0.3s; +} + +.nutrition-category-btn.selected { + background-color: #1890ff; + border-color: #1890ff; color: #fff; - border: none; - border-radius: 4rpx; +} + +.nutrition-category-btn:active { + transform: scale(0.95); } -- Gitee From 654dc09ac1940af6fc3205877baa0fd59be152e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Tue, 4 Nov 2025 10:41:21 +0800 Subject: [PATCH 19/44] =?UTF-8?q?=E5=8E=A8=E7=A5=9E=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/chef/dish-edit/dish-edit.wxss | 10 +++-- miniprogram/pages/gourmet/chefs/chefs.wxml | 18 ++++---- miniprogram/pages/gourmet/chefs/chefs.wxss | 44 ++++++++++++++----- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.wxss b/miniprogram/pages/chef/dish-edit/dish-edit.wxss index a33d1b1..ec2241a 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.wxss +++ b/miniprogram/pages/chef/dish-edit/dish-edit.wxss @@ -73,9 +73,7 @@ background-color: #1890ff; color: #fff; border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; + position: relative; flex-shrink: 0; padding: 0; margin: 0; @@ -83,14 +81,18 @@ } .add-btn-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); font-size: 40rpx; font-weight: normal; line-height: 1; - display: block; margin: 0; padding: 0; color: #fff; text-align: center; + white-space: nowrap; } .ingredient-list { diff --git a/miniprogram/pages/gourmet/chefs/chefs.wxml b/miniprogram/pages/gourmet/chefs/chefs.wxml index b10c54d..6bcc8d3 100644 --- a/miniprogram/pages/gourmet/chefs/chefs.wxml +++ b/miniprogram/pages/gourmet/chefs/chefs.wxml @@ -35,7 +35,7 @@ 清空 - {{item}} - + @@ -61,14 +61,16 @@ bindtap="viewChefDetail" data-id="{{item.id}}" > - + - {{item.nickname || item.username}} + + {{item.nickname || item.username}} + ID: {{item.id}} + - {{item.dish_count || 0}}道菜品 - {{item.meal_set_count || 0}}个套餐 + {{item.dish_count || 0}}道菜 + {{item.meal_set_count || 0}}个套餐 - ID: {{item.id}} - - + + 配餐计划 + {{date}} + + + + + - {{gourmet.nickname}} - 配餐计划详情 + {{gourmet.nickname || '食神' + gourmet.id}} + 的配餐计划 - - - - - - - 开始日期 - {{startDate}} - - - - - - 结束日期 - {{endDate}} - - - - - - - - {{mealType === '' ? '全部餐别' : mealType === 'breakfast' ? '早餐' : mealType === 'lunch' ? '午餐' : '晚餐'}} - > - - - + + + 加载中... - - - - - {{stats.total_plans}} - 总计划 - - - {{stats.breakfast_count}} - 早餐 - - - {{stats.lunch_count}} - 午餐 - - - {{stats.dinner_count}} - 晚餐 + + + + + + {{mealTypeNames[mealType]}} + - - - - - - 配餐计划 - - - - - - - - {{item.date}} - {{item.meal_type === 'breakfast' ? '早餐' : item.meal_type === 'lunch' ? '午餐' : '晚餐'}} - - - - - + + + + 📦 {{plans[mealType].meal_set.name}} + {{plans[mealType].meal_set.dishes.length}}道菜 - - - - 菜品 ({{item.dishes.length}}) - - - - - {{dish.name}} - {{dish.category === 'vegetable' ? '蔬菜' : dish.category === 'protein' ? '蛋白质' : dish.category === 'carb' ? '碳水' : '脂肪'}} - - - - - - - 套餐 - - {{item.meal_set.name}} - {{item.meal_set.dishes.length}}个菜品 + + + + + + + + + {{dish.name}} + {{dish.dish_type_display || dish.category_display || '未分类'}} - - - 备注 - {{item.notes}} - - - - - {{loading ? '加载中...' : '加载更多'}} - - - - - 📅 - 该时间段内暂无配餐计划 + + + 暂无配餐计划 + diff --git a/miniprogram/pages/chef/gourmet-plans/gourmet-plans.wxss b/miniprogram/pages/chef/gourmet-plans/gourmet-plans.wxss index d7d6890..df48276 100644 --- a/miniprogram/pages/chef/gourmet-plans/gourmet-plans.wxss +++ b/miniprogram/pages/chef/gourmet-plans/gourmet-plans.wxss @@ -1,398 +1,197 @@ -/* 食神详细配餐计划页面样式 */ .container { - background-color: #f5f5f5; + padding: 20rpx; + background: #f5f5f5; min-height: 100vh; } -/* 头部 */ +/* 头部样式 */ .header { - background: white; - padding: 20rpx; - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); + background: #fff; + border-radius: 16rpx; + padding: 30rpx; + margin-bottom: 20rpx; + box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } .header-content { display: flex; - align-items: center; - justify-content: space-between; + flex-direction: column; + gap: 20rpx; } -.btn-back { - width: 60rpx; - height: 60rpx; - background: #f8f9fa; - border: none; - border-radius: 50%; +.header-top { display: flex; align-items: center; - justify-content: center; + justify-content: space-between; } -.btn-back .icon { - font-size: 32rpx; +.title { + font-size: 36rpx; + font-weight: bold; + color: #333; +} + +.date { + font-size: 28rpx; color: #666; } .gourmet-info { display: flex; align-items: center; - flex: 1; - margin: 0 20rpx; + gap: 16rpx; + padding-top: 20rpx; + border-top: 1rpx solid #f0f0f0; } .gourmet-avatar { width: 80rpx; height: 80rpx; border-radius: 50%; - margin-right: 20rpx; + border: 2rpx solid #e9ecef; } .gourmet-details { display: flex; flex-direction: column; + gap: 4rpx; } .gourmet-name { - font-size: 32rpx; + font-size: 30rpx; font-weight: bold; color: #333; - margin-bottom: 4rpx; } .gourmet-subtitle { font-size: 24rpx; - color: #666; -} - -.btn-export { - width: 60rpx; - height: 60rpx; - background: #f8f9fa; - border: none; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; -} - -.btn-export .icon { - font-size: 32rpx; - color: #666; -} - -/* 筛选器 */ -.filters { - background: white; - padding: 20rpx; - margin: 20rpx; - border-radius: 12rpx; - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); -} - -.date-range { - display: flex; - align-items: center; - margin-bottom: 20rpx; -} - -.date-picker { - display: flex; - flex-direction: column; - align-items: center; - padding: 16rpx; - background: #f8f9fa; - border-radius: 8rpx; - flex: 1; -} - -.date-label { - font-size: 24rpx; - color: #666; - margin-bottom: 8rpx; -} - -.date-value { - font-size: 28rpx; - font-weight: bold; - color: #333; -} - -.date-separator { - font-size: 24rpx; - color: #666; - margin: 0 16rpx; -} - -.meal-type-filter { - margin-top: 16rpx; -} - -.picker-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 20rpx; - background: #f8f9fa; - border-radius: 8rpx; - font-size: 28rpx; - color: #333; -} - -.picker-arrow { color: #999; - font-size: 24rpx; } -/* 统计信息 */ -.stats-section { - margin: 20rpx; - background: white; - padding: 20rpx; - border-radius: 12rpx; - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 16rpx; -} - -.stat-item { +/* 餐次列表 */ +.meal-list { display: flex; flex-direction: column; - align-items: center; - padding: 20rpx; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: 12rpx; - color: white; -} - -.stat-number { - font-size: 36rpx; - font-weight: bold; - margin-bottom: 8rpx; -} - -.stat-label { - font-size: 24rpx; - opacity: 0.9; + gap: 20rpx; } -/* 配餐计划列表 */ -.plans-section { - margin: 20rpx; +.meal-card { + background: #fff; + border-radius: 16rpx; + padding: 24rpx; + box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } -.section-header { +.meal-header { display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; margin-bottom: 20rpx; + padding-bottom: 16rpx; + border-bottom: 1rpx solid #f0f0f0; } -.section-title { - font-size: 32rpx; - font-weight: bold; - color: #333; -} - -.btn-refresh { - width: 60rpx; - height: 60rpx; - background: #f8f9fa; - border: none; - border-radius: 50%; +.meal-info { display: flex; align-items: center; - justify-content: center; + gap: 12rpx; } -.btn-refresh .icon { +.meal-name { font-size: 32rpx; - color: #666; + font-weight: bold; + color: #333; } -.plans-list { - display: flex; - flex-direction: column; - gap: 20rpx; +/* 套餐信息 */ +.meal-set-info { + margin-bottom: 20rpx; } -.plan-card { - background: white; +.meal-set-card { + background: linear-gradient(135deg, #e6f7ff, #bae7ff); border-radius: 12rpx; padding: 20rpx; - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); -} - -.plan-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16rpx; - padding-bottom: 16rpx; - border-bottom: 1rpx solid #f0f0f0; -} - -.plan-date { display: flex; flex-direction: column; + gap: 8rpx; } -.date { +.meal-set-name { font-size: 28rpx; font-weight: bold; - color: #333; - margin-bottom: 4rpx; -} - -.meal-type { - font-size: 24rpx; - color: #666; + color: #1890ff; } -.plan-actions { - display: flex; - gap: 12rpx; -} - -.btn-action { - width: 48rpx; - height: 48rpx; - background: #f8f9fa; - border: none; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; -} - -.btn-action .icon { +.meal-set-dishes { font-size: 24rpx; color: #666; } -.plan-content { +/* 菜品列表 */ +.dishes-list { display: flex; flex-direction: column; gap: 16rpx; } -.dishes-section, .meal-set-section, .notes-section { - display: flex; - flex-direction: column; -} - -.dishes-title, .meal-set-title, .notes-title { - font-size: 26rpx; - font-weight: bold; - color: #333; - margin-bottom: 12rpx; -} - -.dishes-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200rpx, 1fr)); - gap: 12rpx; +.dish-item { + background: #fafafa; + border-radius: 12rpx; + overflow: hidden; } -.dish-item { +.dish-content { display: flex; - flex-direction: column; align-items: center; + gap: 16rpx; padding: 16rpx; - background: #f8f9fa; - border-radius: 8rpx; } .dish-image { - width: 80rpx; - height: 80rpx; - border-radius: 8rpx; - margin-bottom: 8rpx; + width: 120rpx; + height: 120rpx; + border-radius: 12rpx; + flex-shrink: 0; } .dish-info { + flex: 1; display: flex; flex-direction: column; - align-items: center; + gap: 8rpx; } .dish-name { - font-size: 24rpx; + font-size: 30rpx; + font-weight: bold; color: #333; - margin-bottom: 4rpx; - text-align: center; } .dish-category { - font-size: 20rpx; - color: #666; -} - -.meal-set-item { - padding: 16rpx; - background: #f8f9fa; - border-radius: 8rpx; -} - -.meal-set-name { - font-size: 26rpx; - color: #333; - margin-bottom: 4rpx; - display: block; -} - -.meal-set-dishes { - font-size: 22rpx; - color: #666; -} - -.notes-content { font-size: 24rpx; color: #666; - line-height: 1.5; - padding: 16rpx; - background: #f8f9fa; + background: #e6f7ff; + padding: 4rpx 12rpx; border-radius: 8rpx; -} - -/* 加载更多 */ -.load-more { - display: flex; - justify-content: center; - align-items: center; - padding: 40rpx; - background: white; - border-radius: 12rpx; - margin-top: 20rpx; - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); -} - -.load-more-text { - font-size: 28rpx; - color: #666; + align-self: flex-start; } /* 空状态 */ .empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 100rpx 20rpx; - background: white; - border-radius: 12rpx; - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); -} - -.empty-icon { - font-size: 80rpx; - margin-bottom: 20rpx; + padding: 60rpx 20rpx; + text-align: center; } .empty-text { font-size: 28rpx; + color: #999; +} + +/* 加载状态 */ +.loading { + text-align: center; + padding: 60rpx 20rpx; color: #666; + font-size: 28rpx; } diff --git a/miniprogram/pages/chef/shopping-list/shopping-list.js b/miniprogram/pages/chef/shopping-list/shopping-list.js index e870ebe..c6c4cec 100644 --- a/miniprogram/pages/chef/shopping-list/shopping-list.js +++ b/miniprogram/pages/chef/shopping-list/shopping-list.js @@ -15,7 +15,10 @@ Page({ includeNutritionInfo: false, // 是否包含营养信息 mealTypes: [], // 餐别筛选 showFilters: false, // 是否显示筛选器 - exportFormat: 'list' // 导出格式:list, categorized + exportFormat: 'list', // 导出格式:list, categorized + dates: '', // 从计划页面传入的日期列表(逗号分隔) + datesCount: 0, // 日期数量 + fromPlanPage: false // 是否从计划页面跳转过来 }, onLoad(options) { @@ -40,6 +43,16 @@ Page({ console.log('[ShoppingList] 从参数获取结束日期', { endDate: options.endDate }) this.setData({ endDate: options.endDate }) } + // 从计划页面传入的日期列表 + if (options.dates) { + console.log('[ShoppingList] 从计划页面传入日期列表', { dates: options.dates }) + const datesArray = options.dates.split(',').filter(d => d) // 过滤空字符串 + this.setData({ + dates: options.dates, + datesCount: datesArray.length, // 计算日期数量 + fromPlanPage: true // 标记为从计划页面跳转 + }) + } }, onShow() { @@ -81,10 +94,11 @@ Page({ selectAll: selectedIds.length === gourmets.length && gourmets.length > 0 }) - // 如果有选中的食神,自动生成清单 - if (selectedIds.length > 0 && gourmets.length > 0) { - console.log('[ShoppingList] 自动生成采购清单', { + // 如果从计划页面跳转过来,自动生成清单 + if (this.data.fromPlanPage && selectedIds.length > 0 && gourmets.length > 0) { + console.log('[ShoppingList] 从计划页面跳转,自动生成采购清单', { selectedIds, + dates: this.data.dates, gourmetsCount: gourmets.length, timestamp: new Date().toISOString() }) @@ -186,6 +200,8 @@ Page({ selectedGourmetIds: this.data.selectedGourmetIds, startDate: this.data.startDate, endDate: this.data.endDate, + dates: this.data.dates, + fromPlanPage: this.data.fromPlanPage, gourmetCount: this.data.selectedGourmetIds.length, timestamp: new Date().toISOString() }) @@ -214,6 +230,11 @@ Page({ include_nutrition_info: this.data.includeNutritionInfo } + // 如果从计划页面传入日期列表,使用指定的日期 + if (this.data.dates && this.data.dates.length > 0) { + params.dates = this.data.dates // 传递日期列表给后端 + } + if (this.data.mealTypes.length > 0) { params.meal_types = this.data.mealTypes } diff --git a/miniprogram/pages/chef/shopping-list/shopping-list.wxml b/miniprogram/pages/chef/shopping-list/shopping-list.wxml index 156d7ec..cf4b919 100644 --- a/miniprogram/pages/chef/shopping-list/shopping-list.wxml +++ b/miniprogram/pages/chef/shopping-list/shopping-list.wxml @@ -1,6 +1,6 @@ - - + + 选择日期范围 @@ -19,8 +19,8 @@ - - + + 选择食神 @@ -48,12 +48,17 @@ - 暂无绑定的食神 +暂无绑定的食神 - - + + + 已选择 {{selectedGourmetIds.length}} 位食神 · {{datesCount}} 天的计划 + + + + diff --git a/miniprogram/pages/chef/shopping-list/shopping-list.wxss b/miniprogram/pages/chef/shopping-list/shopping-list.wxss index fc3caac..b269000 100644 --- a/miniprogram/pages/chef/shopping-list/shopping-list.wxss +++ b/miniprogram/pages/chef/shopping-list/shopping-list.wxss @@ -8,6 +8,19 @@ } /* 页面容器 */ +.info-section { + background: #e6f7ff; + border-radius: 12rpx; + padding: 20rpx; + margin: 20rpx; + text-align: center; +} + +.info-text { + font-size: 28rpx; + color: #1890ff; +} + .container { padding: 20rpx; min-height: 100vh; diff --git a/miniprogram/pages/feedback/feedback.wxml b/miniprogram/pages/feedback/feedback.wxml index f5c5be1..68110cb 100644 --- a/miniprogram/pages/feedback/feedback.wxml +++ b/miniprogram/pages/feedback/feedback.wxml @@ -67,12 +67,11 @@ - + diff --git a/miniprogram/pages/feedback/feedback.wxss b/miniprogram/pages/feedback/feedback.wxss index 08455b1..573eb8c 100644 --- a/miniprogram/pages/feedback/feedback.wxss +++ b/miniprogram/pages/feedback/feedback.wxss @@ -43,6 +43,9 @@ font-size: 28rpx; color: #333; background-color: #fff; + display: flex; + align-items: center; + box-sizing: border-box; } .form-input:focus { @@ -100,6 +103,10 @@ font-size: 32rpx; font-weight: 500; border: none; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; } .btn-primary { @@ -107,7 +114,8 @@ color: #fff; } -.btn-primary:disabled { +.btn-primary.disabled { background-color: #d9d9d9; color: #999; + pointer-events: none; } diff --git a/miniprogram/pages/plan/plan.js b/miniprogram/pages/plan/plan.js index 0595f40..b233d81 100644 --- a/miniprogram/pages/plan/plan.js +++ b/miniprogram/pages/plan/plan.js @@ -14,6 +14,7 @@ Page({ plansData: [], // 保存原始计划数据(包含完整信息) selectedStartDate: '', // 选择的开始日期 selectedEndDate: '', // 选择的结束日期 + selectedDates: [], // 选中的日期列表(用于生成采购清单) // 食神数据:我的厨神列表 chefs: [], @@ -53,7 +54,7 @@ Page({ loadGourmets() { console.log('[Plan] 开始加载绑定的食神列表', { timestamp: new Date().toISOString() }) this.setData({ loading: true }) - get('/api/chef/gourmets/') + return get('/api/chef/gourmets/') .then(res => { // /api/chef/gourmets/ 返回的是食神用户数组(已过滤为accepted状态) const gourmets = res.results || res || [] @@ -68,6 +69,7 @@ Page({ gourmets, loading: false }) + return gourmets }) .catch(err => { console.error('[Plan] 加载食神列表失败', { @@ -77,6 +79,7 @@ Page({ }) this.setData({ loading: false }) showError(err.message || '加载失败') + return Promise.reject(err) }) }, @@ -165,6 +168,8 @@ Page({ const order = { breakfast: 1, lunch: 2, dinner: 3 } return (order[a.meal_type] || 99) - (order[b.meal_type] || 99) }) + // 初始化选中状态 + plan.selected = this.data.selectedDates.includes(plan.date) }) console.log('[Plan] 计划数据转换完成', { @@ -210,12 +215,37 @@ Page({ return names[mealType] || mealType }, + // 切换日期选择 + toggleDateSelect(e) { + const date = e.currentTarget.dataset.date + const selectedDates = [...this.data.selectedDates] + const index = selectedDates.indexOf(date) + + if (index > -1) { + selectedDates.splice(index, 1) + } else { + selectedDates.push(date) + } + + // 更新计划项的选中状态 + const plans = this.data.plans.map(plan => { + if (plan.date === date) { + return { ...plan, selected: !plan.selected } + } + return plan + }) + + this.setData({ + selectedDates, + plans + }) + }, + // 厨神:生成采购清单 generateShoppingList() { console.log('[Plan] 点击生成采购清单', { selectedGourmet: this.data.selectedGourmet, - selectedStartDate: this.data.selectedStartDate, - selectedEndDate: this.data.selectedEndDate, + selectedDates: this.data.selectedDates, timestamp: new Date().toISOString() }) @@ -225,26 +255,30 @@ Page({ return } - // 如果有选择的日期范围,使用选择的日期;否则使用未来7天 - let startDate = this.data.selectedStartDate - let endDate = this.data.selectedEndDate - - if (!startDate) { - startDate = new Date().toISOString().split('T')[0] - } - if (!endDate) { - endDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + if (this.data.selectedDates.length === 0) { + console.warn('[Plan] 生成采购清单失败:未选择日期') + showError('请至少选择一个日期') + return } + // 计算日期范围(最小和最大日期) + const sortedDates = [...this.data.selectedDates].sort() + const startDate = sortedDates[0] + const endDate = sortedDates[sortedDates.length - 1] + + // 将选中的日期列表转换为逗号分隔的字符串 + const datesStr = this.data.selectedDates.join(',') + console.log('[Plan] 跳转到采购清单页面', { gourmetId: this.data.selectedGourmet, startDate, endDate, + dates: datesStr, timestamp: new Date().toISOString() }) wx.navigateTo({ - url: `/pages/chef/shopping-list/shopping-list?gourmetId=${this.data.selectedGourmet}&startDate=${startDate}&endDate=${endDate}` + url: `/pages/chef/shopping-list/shopping-list?gourmetId=${this.data.selectedGourmet}&startDate=${startDate}&endDate=${endDate}&dates=${datesStr}` }) }, @@ -356,6 +390,8 @@ Page({ const order = { breakfast: 1, lunch: 2, dinner: 3 } return (order[a.meal_type] || 99) - (order[b.meal_type] || 99) }) + // 初始化选中状态 + plan.selected = this.data.selectedDates.includes(plan.date) }) this.setData({ @@ -380,16 +416,18 @@ Page({ // 食神:加载绑定的厨神列表 loadChefs() { this.setData({ loading: true }) - get('/api/gourmet/bindings/') + return get('/api/gourmet/bindings/') .then(res => { this.setData({ bindings: res.results || res, loading: false }) + return res.results || res }) .catch(err => { this.setData({ loading: false }) showError(err.message || '加载失败') + return Promise.reject(err) }) }, @@ -404,6 +442,32 @@ Page({ wx.redirectTo({ url: '/pages/role-select/role-select' }) + }, + + // 下拉刷新 + onPullDownRefresh() { + console.log('[Plan] 下拉刷新') + + if (this.data.role === 'chef' && this.data.selectedGourmet) { + // 重新加载食神列表和计划 + this.loadGourmets() + .then(() => { + if (this.data.selectedGourmet) { + return this.loadGourmetPlans(this.data.selectedGourmet) + } + }) + .finally(() => { + wx.stopPullDownRefresh() + }) + } else if (this.data.role === 'gourmet') { + // 重新加载厨神列表 + this.loadChefs() + .finally(() => { + wx.stopPullDownRefresh() + }) + } else { + wx.stopPullDownRefresh() + } } }) diff --git a/miniprogram/pages/plan/plan.json b/miniprogram/pages/plan/plan.json index 5ce9f1a..5ee625a 100644 --- a/miniprogram/pages/plan/plan.json +++ b/miniprogram/pages/plan/plan.json @@ -1,2 +1,6 @@ -{"navigationBarTitleText": "计划"} +{ + "navigationBarTitleText": "计划", + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark" +} diff --git a/miniprogram/pages/plan/plan.wxml b/miniprogram/pages/plan/plan.wxml index d50e152..1cc8d44 100644 --- a/miniprogram/pages/plan/plan.wxml +++ b/miniprogram/pages/plan/plan.wxml @@ -46,35 +46,42 @@ - - - - - - - - {{item.date}} - {{item.weekday}} + + + + + - - - {{meal.meal_type_name}} - {{meal.dish_names}} + + + + + {{item.date}} + {{item.weekday}} + + + + + {{meal.meal_type_name}} + {{meal.dish_names}} + - > + + + + + diff --git a/miniprogram/pages/plan/plan.wxss b/miniprogram/pages/plan/plan.wxss index aca1ff5..f2e2e0f 100644 --- a/miniprogram/pages/plan/plan.wxss +++ b/miniprogram/pages/plan/plan.wxss @@ -76,6 +76,7 @@ background: white; border-radius: 10rpx; padding: 20rpx; + padding-bottom: 120rpx; /* 为底部按钮留出空间 */ } .section-header { @@ -110,19 +111,61 @@ padding: 20rpx; background: #fafafa; transition: all 0.3s; + display: flex; + align-items: center; + gap: 20rpx; +} + +.plan-item.selected { + border-color: #1890ff; + background: #e6f7ff; } .plan-item:active { background: #f0f0f0; } +.plan-checkbox { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.checkbox { + width: 40rpx; + height: 40rpx; + border: 2rpx solid #d9d9d9; + border-radius: 6rpx; + display: flex; + align-items: center; + justify-content: center; + background: #fff; + transition: all 0.3s; +} + +.checkbox.checked { + background: #1890ff; + border-color: #1890ff; +} + +.checkmark { + color: #fff; + font-size: 24rpx; + font-weight: bold; +} + +.plan-content { + flex: 1; + position: relative; +} + .plan-arrow { - position: absolute; - right: 20rpx; - top: 50%; - transform: translateY(-50%); - font-size: 32rpx; + font-size: 36rpx; color: #999; + line-height: 1; + letter-spacing: 2rpx; + margin-left: auto; } .plan-date { @@ -254,48 +297,52 @@ display: flex; align-items: center; justify-content: space-between; - margin-bottom: 15rpx; + gap: 20rpx; } .date-picker-item { flex: 1; + min-width: 0; background: white; - padding: 20rpx; + padding: 24rpx 20rpx; border-radius: 8rpx; - text-align: center; + flex-basis: 0; + min-width: 200rpx; } .date-label { - font-size: 24rpx; + font-size: 30rpx; color: #999; display: block; - margin-bottom: 8rpx; + margin-bottom: 12rpx; + text-align: center; } .date-value { - font-size: 28rpx; + font-size: 32rpx; color: #333; font-weight: 500; + text-align: center; + display: block; } .date-separator { - margin: 0 20rpx; color: #666; font-size: 28rpx; -} - -.btn-refresh { - width: 100%; - padding: 20rpx; - background: #1890ff; - color: white; - border-radius: 8rpx; - font-size: 28rpx; - border: none; + flex-shrink: 0; + min-width: 40rpx; + text-align: center; } .action-section { - margin-bottom: 20rpx; + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 20rpx; + background: #fff; + box-shadow: 0 -2rpx 12rpx rgba(0,0,0,0.1); + z-index: 100; } .action-btn { diff --git a/miniprogram/pages/privacy-policy/privacy-policy.js b/miniprogram/pages/privacy-policy/privacy-policy.js index 42f71d0..c09fba5 100644 --- a/miniprogram/pages/privacy-policy/privacy-policy.js +++ b/miniprogram/pages/privacy-policy/privacy-policy.js @@ -44,12 +44,12 @@ Page({ 八、联系我们 8.1 如果您对本隐私政策有任何疑问,请通过以下方式联系我们: - 邮箱:privacy@mealarchitect.com + 邮箱:447083059@qq.com 本隐私政策自您使用本服务之日起生效。 配膳官团队 -2024年 +2025年 ` }, diff --git a/miniprogram/pages/user-agreement/user-agreement.js b/miniprogram/pages/user-agreement/user-agreement.js index 93753a2..bcb89cf 100644 --- a/miniprogram/pages/user-agreement/user-agreement.js +++ b/miniprogram/pages/user-agreement/user-agreement.js @@ -38,7 +38,7 @@ Page({ 本协议自您使用本服务之日起生效。 配膳官团队 -2024年 +2025年 ` }, diff --git a/miniprogram/utils/version.js b/miniprogram/utils/version.js new file mode 100644 index 0000000..37bc528 --- /dev/null +++ b/miniprogram/utils/version.js @@ -0,0 +1,26 @@ +// 版本配置文件 +// 每次上传新版本时,请在此处更新版本号,与上传时填写的版本号保持一致 + +const VERSION_INFO = { + // 应用版本号(主版本号.次版本号.修订号) + // ⚠️ 重要:上传代码时,请确保这里的版本号与上传时填写的版本号一致 + version: '1.0.0', + // 版本名称(可选,如:正式版、测试版等) + versionName: '正式版' +} + +// 获取版本信息 +function getVersionInfo() { + return { + ...VERSION_INFO, + // 完整版本号(包含版本名称) + fullVersion: `${VERSION_INFO.version} (${VERSION_INFO.versionName})` + } +} + +// 导出版本信息 +module.exports = { + VERSION_INFO, + getVersionInfo +} + -- Gitee From 43d117bddd80d384646f6e80e3745a1108d44f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Wed, 5 Nov 2025 12:02:36 +0800 Subject: [PATCH 25/44] =?UTF-8?q?1.=20=E5=9B=BE=E7=89=87=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=85=A2=E9=97=AE=E9=A2=98=E4=BC=98=E5=8C=96=E3=80=822.=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=B8=8A=E4=BC=A0=E5=9B=BE=E7=89=87=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=8A=9F=E8=83=BD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0001_initial_thumbnail.py | 20 + backend/dishes/models.py | 7 + backend/dishes/serializers.py | 69 ++- backend/dishes/views.py | 17 +- .../meal_architect/utils/image_processor.py | 277 +++++++++ backend/users/views.py | 8 +- miniprogram/app.json | 1 + miniprogram/pages/chef/dish-edit/dish-edit.js | 119 +++- .../gourmet/dish-detail/dish-detail.wxml | 1 + miniprogram/pages/home/home.wxml | 1 + miniprogram/pages/image-edit/image-edit.js | 539 ++++++++++++++++++ miniprogram/pages/image-edit/image-edit.json | 7 + miniprogram/pages/image-edit/image-edit.wxml | 44 ++ miniprogram/pages/image-edit/image-edit.wxss | 84 +++ miniprogram/pages/menu/menu.wxml | 1 + ...36\346\226\275\346\200\273\347\273\223.md" | 114 ++++ 16 files changed, 1247 insertions(+), 62 deletions(-) create mode 100644 backend/dishes/migrations/0001_initial_thumbnail.py create mode 100644 backend/meal_architect/utils/image_processor.py create mode 100644 miniprogram/pages/image-edit/image-edit.js create mode 100644 miniprogram/pages/image-edit/image-edit.json create mode 100644 miniprogram/pages/image-edit/image-edit.wxml create mode 100644 miniprogram/pages/image-edit/image-edit.wxss create mode 100644 "\345\233\276\347\211\207\344\274\230\345\214\226\345\256\236\346\226\275\346\200\273\347\273\223.md" diff --git a/backend/dishes/migrations/0001_initial_thumbnail.py b/backend/dishes/migrations/0001_initial_thumbnail.py new file mode 100644 index 0000000..58206fe --- /dev/null +++ b/backend/dishes/migrations/0001_initial_thumbnail.py @@ -0,0 +1,20 @@ +# Generated migration for adding thumbnail field to DishImage + +from django.db import migrations, models +import dishes.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dishes', '0001_initial'), # 请根据实际的最新迁移文件修改 + ] + + operations = [ + migrations.AddField( + model_name='dishimage', + name='thumbnail', + field=models.ImageField(blank=True, null=True, upload_to=dishes.models.dish_thumbnail_upload_path, verbose_name='缩略图'), + ), + ] + diff --git a/backend/dishes/models.py b/backend/dishes/models.py index 1a0194a..376bcd3 100644 --- a/backend/dishes/models.py +++ b/backend/dishes/models.py @@ -100,6 +100,12 @@ def dish_image_upload_path(instance, filename): return f'dishes/images/chef_{instance.dish.chef.id}/dish_{instance.dish.id}/{filename}' +def dish_thumbnail_upload_path(instance, filename): + """生成菜品缩略图的存储路径""" + # 格式:dishes/thumbnails/chef_id/dish_id/filename + return f'dishes/thumbnails/chef_{instance.dish.chef.id}/dish_{instance.dish.id}/{filename}' + + class DishImage(models.Model): """菜品图片模型""" dish = models.ForeignKey( @@ -109,6 +115,7 @@ class DishImage(models.Model): verbose_name='菜品' ) image = models.ImageField('图片', upload_to=dish_image_upload_path, null=True, blank=True) + thumbnail = models.ImageField('缩略图', upload_to=dish_thumbnail_upload_path, null=True, blank=True) order = models.IntegerField('排序', default=0) created_at = models.DateTimeField('创建时间', auto_now_add=True) diff --git a/backend/dishes/serializers.py b/backend/dishes/serializers.py index d9819f4..bf545fa 100644 --- a/backend/dishes/serializers.py +++ b/backend/dishes/serializers.py @@ -37,29 +37,38 @@ class IngredientSerializer(serializers.ModelSerializer): class DishImageSerializer(serializers.ModelSerializer): """菜品图片序列化器""" image_url = serializers.SerializerMethodField() + thumbnail_url = serializers.SerializerMethodField() class Meta: model = DishImage - fields = ['id', 'image', 'image_url', 'order'] + fields = ['id', 'image', 'image_url', 'thumbnail', 'thumbnail_url', 'order'] read_only_fields = ['id'] - def get_image_url(self, obj): - """获取图片的完整URL""" - if obj.image: + def _build_image_url(self, image_field, obj): + """构建图片URL的辅助方法""" + if image_field: request = self.context.get('request') if request: - return request.build_absolute_uri(obj.image.url) + return request.build_absolute_uri(image_field.url) else: # 如果没有request上下文,手动构建完整URL from django.conf import settings base_url = getattr(settings, 'BASE_URL', 'http://localhost:8000') if not base_url.endswith('/'): base_url += '/' - if obj.image.url.startswith('/'): - return f"{base_url.rstrip('/')}{obj.image.url}" + if image_field.url.startswith('/'): + return f"{base_url.rstrip('/')}{image_field.url}" else: - return f"{base_url}{obj.image.url}" + return f"{base_url}{image_field.url}" return None + + def get_image_url(self, obj): + """获取原图的完整URL""" + return self._build_image_url(obj.image, obj) + + def get_thumbnail_url(self, obj): + """获取缩略图的完整URL""" + return self._build_image_url(obj.thumbnail, obj) class CookingStepSerializer(serializers.ModelSerializer): @@ -211,7 +220,7 @@ class DishSerializer(serializers.ModelSerializer): # 创建图片 for image_data in images_data: - # 如果传入的是文件对象,直接使用 + # 如果传入的是文件对象,直接使用(包含image和thumbnail) if 'image' in image_data: DishImage.objects.create(dish=dish, **image_data) # 如果传入的是URL(兼容旧数据),跳过 @@ -308,26 +317,32 @@ class DishListSerializer(serializers.ModelSerializer): return '' def get_main_image(self, obj): - """获取图片的完整URL""" + """获取图片的完整URL(优先返回缩略图,用于列表页)""" first_image = obj.images.first() - if first_image and first_image.image: - request = self.context.get('request') - if request: - # 确保URL是完整的绝对路径 - image_url = request.build_absolute_uri(first_image.image.url) - return image_url + if not first_image: + return None + + # 优先使用缩略图(列表页使用) + image_field = first_image.thumbnail if first_image.thumbnail else first_image.image + if not image_field: + return None + + request = self.context.get('request') + if request: + # 确保URL是完整的绝对路径 + image_url = request.build_absolute_uri(image_field.url) + return image_url + else: + # 如果没有request上下文,手动构建完整URL + from django.conf import settings + base_url = getattr(settings, 'BASE_URL', 'http://localhost:8000') + if not base_url.endswith('/'): + base_url += '/' + if image_field.url.startswith('/'): + image_url = f"{base_url.rstrip('/')}{image_field.url}" else: - # 如果没有request上下文,手动构建完整URL - from django.conf import settings - base_url = getattr(settings, 'BASE_URL', 'http://localhost:8000') - if not base_url.endswith('/'): - base_url += '/' - if first_image.image.url.startswith('/'): - image_url = f"{base_url.rstrip('/')}{first_image.image.url}" - else: - image_url = f"{base_url}{first_image.image.url}" - return image_url - return None + image_url = f"{base_url}{image_field.url}" + return image_url class ChefGourmetBindingSerializer(serializers.ModelSerializer): diff --git a/backend/dishes/views.py b/backend/dishes/views.py index 9165a3f..8b2c865 100644 --- a/backend/dishes/views.py +++ b/backend/dishes/views.py @@ -6,6 +6,7 @@ from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from django.db.models import Q from django.conf import settings from meal_architect.utils.log_manager import logger +from meal_architect.utils.image_processor import process_dish_image, process_step_image from .models import Dish, ChefGourmetBinding, DishImage, CookingStep, NutritionCategory from .serializers import ( DishSerializer, DishListSerializer, @@ -57,8 +58,11 @@ class DishViewSet(viewsets.ModelViewSet): if 'images' in request.FILES: images_data = [] for i, image_file in enumerate(request.FILES.getlist('images')): + # 处理图片:压缩并生成缩略图 + compressed_image, thumbnail = process_dish_image(image_file) images_data.append({ - 'image': image_file, + 'image': compressed_image, + 'thumbnail': thumbnail, 'order': i }) request.data['images'] = images_data @@ -125,10 +129,14 @@ class DishViewSet(viewsets.ModelViewSet): order = int(request.data.get('order', 0)) try: + # 处理图片:压缩并生成缩略图 + compressed_image, thumbnail = process_dish_image(image_file) + # 创建图片记录 dish_image = DishImage.objects.create( dish=dish, - image=image_file, + image=compressed_image, + thumbnail=thumbnail, order=order ) @@ -337,8 +345,11 @@ def upload_step_image(request, dish_id, step_number): logger.log_e(f"步骤不存在: dish_id={dish_id}, step_number={step_number}") return Response({'error': f'步骤{step_number}不存在,请先创建菜品'}, status=status.HTTP_400_BAD_REQUEST) + # 处理图片:压缩 + compressed_image = process_step_image(image_file) + # 更新图片 - cooking_step.image = image_file + cooking_step.image = compressed_image cooking_step.save() logger.log_d(f"上传步骤图片成功: dish_id={dish_id}, step_number={step_number}") diff --git a/backend/meal_architect/utils/image_processor.py b/backend/meal_architect/utils/image_processor.py new file mode 100644 index 0000000..ed5c099 --- /dev/null +++ b/backend/meal_architect/utils/image_processor.py @@ -0,0 +1,277 @@ +""" +图片处理工具类 +用于压缩图片、生成缩略图等 +""" +import io +from PIL import Image +from django.core.files.uploadedfile import InMemoryUploadedFile +import sys + + +def compress_image(image_file, max_size=(1920, 1080), quality=85): + """ + 压缩图片 + + Args: + image_file: Django上传的文件对象 + max_size: 最大尺寸 (width, height),默认 (1920, 1080) + quality: JPEG质量 (1-100),默认85 + + Returns: + 压缩后的InMemoryUploadedFile对象 + """ + try: + # 打开图片 + img = Image.open(image_file) + + # 获取原始文件名和扩展名 + original_name = image_file.name + ext = original_name.split('.')[-1].lower() if '.' in original_name else 'jpg' + + # 转换模式为RGB(JPEG不支持透明通道) + if img.mode in ('RGBA', 'LA', 'P'): + # 如果有透明通道,创建白色背景 + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + # 调整尺寸(保持宽高比) + img.thumbnail(max_size, Image.Resampling.LANCZOS) + + # 压缩保存到内存 + output = io.BytesIO() + img.save(output, format='JPEG', quality=quality, optimize=True) + output.seek(0) + + # 创建新的文件对象 + file_name = f"{original_name.rsplit('.', 1)[0] if '.' in original_name else 'image'}.jpg" + compressed_file = InMemoryUploadedFile( + output, + 'ImageField', + file_name, + 'image/jpeg', + sys.getsizeof(output), + None + ) + + return compressed_file + + except Exception as e: + # 如果处理失败,返回原文件 + print(f"图片压缩失败: {str(e)}") + image_file.seek(0) # 重置文件指针 + return image_file + + +def generate_thumbnail(image_file, size=(400, 300), quality=80): + """ + 生成缩略图 + + Args: + image_file: Django上传的文件对象或PIL Image对象 + size: 缩略图尺寸 (width, height),默认 (400, 300) + quality: JPEG质量 (1-100),默认80 + + Returns: + 缩略图的InMemoryUploadedFile对象 + """ + try: + # 如果是文件对象,打开图片;如果是Image对象,直接使用 + if isinstance(image_file, Image.Image): + img = image_file.copy() + original_name = 'thumbnail.jpg' + else: + img = Image.open(image_file) + original_name = image_file.name + + ext = original_name.split('.')[-1].lower() if '.' in original_name else 'jpg' + + # 转换模式为RGB + if img.mode in ('RGBA', 'LA', 'P'): + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + # 生成缩略图(保持宽高比) + img.thumbnail(size, Image.Resampling.LANCZOS) + + # 保存到内存 + output = io.BytesIO() + img.save(output, format='JPEG', quality=quality, optimize=True) + output.seek(0) + + # 创建新的文件对象 + file_name = f"{original_name.rsplit('.', 1)[0] if '.' in original_name else 'thumbnail'}_thumb.jpg" + thumbnail_file = InMemoryUploadedFile( + output, + 'ImageField', + file_name, + 'image/jpeg', + sys.getsizeof(output), + None + ) + + return thumbnail_file + + except Exception as e: + print(f"生成缩略图失败: {str(e)}") + return None + + +def process_dish_image(image_file): + """ + 处理菜品图片:压缩原图并生成缩略图 + + Args: + image_file: Django上传的文件对象 + + Returns: + tuple: (压缩后的原图文件, 缩略图文件) + """ + # 重置文件指针 + image_file.seek(0) + + # 压缩原图 + compressed_image = compress_image(image_file, max_size=(1920, 1080), quality=85) + + # 生成缩略图(从压缩后的图片生成) + compressed_image.seek(0) + thumbnail = generate_thumbnail(compressed_image, size=(400, 300), quality=80) + + return compressed_image, thumbnail + + +def process_avatar_image(image_file): + """ + 处理头像图片:压缩 + + Args: + image_file: Django上传的文件对象 + + Returns: + 压缩后的文件对象 + """ + # 头像使用较小的尺寸 + image_file.seek(0) + compressed_image = compress_image(image_file, max_size=(800, 800), quality=85) + return compressed_image + + +def resize_to_fixed_size(image_file, target_size=(750, 750), quality=85): + """ + 将图片调整到固定尺寸(保持宽高比,居中裁剪或填充) + + Args: + image_file: Django上传的文件对象 + target_size: 目标尺寸 (width, height),默认 (750, 750) + quality: JPEG质量 (1-100),默认85 + + Returns: + 调整后的InMemoryUploadedFile对象 + """ + try: + # 打开图片 + img = Image.open(image_file) + + # 获取原始文件名 + original_name = image_file.name + + # 转换模式为RGB + if img.mode in ('RGBA', 'LA', 'P'): + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + target_width, target_height = target_size + img_width, img_height = img.size + + # 计算缩放比例(保持宽高比) + scale = max(target_width / img_width, target_height / img_height) + + # 先缩放图片 + new_width = int(img_width * scale) + new_height = int(img_height * scale) + img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # 居中裁剪到目标尺寸 + left = (new_width - target_width) // 2 + top = (new_height - target_height) // 2 + right = left + target_width + bottom = top + target_height + + img = img.crop((left, top, right, bottom)) + + # 保存到内存 + output = io.BytesIO() + img.save(output, format='JPEG', quality=quality, optimize=True) + output.seek(0) + + # 创建新的文件对象 + file_name = f"{original_name.rsplit('.', 1)[0] if '.' in original_name else 'image'}.jpg" + resized_file = InMemoryUploadedFile( + output, + 'ImageField', + file_name, + 'image/jpeg', + sys.getsizeof(output), + None + ) + + return resized_file + + except Exception as e: + print(f"调整图片尺寸失败: {str(e)}") + image_file.seek(0) + return image_file + + +def process_dish_image(image_file): + """ + 处理菜品图片:调整到固定尺寸750x750并生成缩略图 + + Args: + image_file: Django上传的文件对象 + + Returns: + tuple: (调整后的原图文件(750x750), 缩略图文件) + """ + # 重置文件指针 + image_file.seek(0) + + # 调整到固定尺寸750x750 + resized_image = resize_to_fixed_size(image_file, target_size=(750, 750), quality=85) + + # 生成缩略图(从调整后的图片生成) + resized_image.seek(0) + thumbnail = generate_thumbnail(resized_image, size=(400, 300), quality=80) + + return resized_image, thumbnail + + +def process_step_image(image_file): + """ + 处理制作步骤图片:调整到固定尺寸1280x960 + + Args: + image_file: Django上传的文件对象 + + Returns: + 调整后的文件对象(1280x960) + """ + # 步骤图片固定尺寸1280x960(4:3比例) + image_file.seek(0) + resized_image = resize_to_fixed_size(image_file, target_size=(1280, 960), quality=85) + return resized_image + diff --git a/backend/users/views.py b/backend/users/views.py index 4a61d09..cbc7eee 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -262,13 +262,17 @@ def upload_avatar(request): filename = f"avatar_{request.user.id}_{uuid.uuid4().hex}{ext}" logger.log_d(f"生成的文件名: {filename}") + # 处理图片:压缩 + from meal_architect.utils.image_processor import process_avatar_image + compressed_image = process_avatar_image(image_file) + # 构建存储路径 upload_path = f"users/avatars/{filename}" logger.log_d(f"存储路径: {upload_path}") - # 保存文件到media目录 + # 保存压缩后的文件到media目录 from django.core.files.storage import default_storage - file_path = default_storage.save(upload_path, image_file) + file_path = default_storage.save(upload_path, compressed_image) logger.log_d(f"文件保存成功: {file_path}") # 生成访问URL diff --git a/miniprogram/app.json b/miniprogram/app.json index eb6191f..b51ffb1 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -8,6 +8,7 @@ "pages/profile/profile", "pages/profile-edit/profile-edit", "pages/avatar-crop/avatar-crop", + "pages/image-edit/image-edit", "pages/settings/settings", "pages/feedback/feedback", "pages/about/about", diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.js b/miniprogram/pages/chef/dish-edit/dish-edit.js index 8785a49..ced3116 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.js +++ b/miniprogram/pages/chef/dish-edit/dish-edit.js @@ -394,22 +394,34 @@ Page({ wx.chooseImage({ count: 1, - sizeType: ['compressed'], + sizeType: ['original'], // 使用原图,编辑后再处理 sourceType: ['album', 'camera'], success: (res) => { + // 跳转到图片编辑页面 const tempPath = res.tempFilePaths[0] - const steps = this.data.formData.cooking_steps - steps[index].tempPath = tempPath - steps[index].image_url = null - steps[index].image = null - - this.setData({ - 'formData.cooking_steps': steps + wx.navigateTo({ + url: `/pages/image-edit/image-edit?src=${encodeURIComponent(tempPath)}&type=step&stepIndex=${index}` }) } }) }, + /** + * 处理步骤图片编辑后的回调 + */ + handleStepImageEdited(editedImagePath, stepIndex) { + const steps = this.data.formData.cooking_steps + if (stepIndex !== undefined && steps[stepIndex]) { + steps[stepIndex].tempPath = editedImagePath + steps[stepIndex].image_url = null + steps[stepIndex].image = null + + this.setData({ + 'formData.cooking_steps': steps + }) + } + }, + /** * 删除步骤图片 */ @@ -499,26 +511,40 @@ Page({ * 选择图片 */ chooseImage() { + const remainingCount = 9 - this.data.formData.images.length + if (remainingCount <= 0) { + showError('最多只能上传9张图片') + return + } + wx.chooseImage({ - count: 9 - this.data.formData.images.length, - sizeType: ['compressed'], + count: 1, // 每次只选择一张,跳转到编辑页面 + sizeType: ['original'], // 使用原图,编辑后再处理 sourceType: ['album', 'camera'], success: res => { - // 直接保存临时文件路径,在保存菜品时一起上传 - const images = this.data.formData.images - res.tempFilePaths.forEach((path, index) => { - images.push({ - tempPath: path, - order: images.length - }) - }) - this.setData({ - 'formData.images': images + // 跳转到图片编辑页面 + const tempPath = res.tempFilePaths[0] + wx.navigateTo({ + url: `/pages/image-edit/image-edit?src=${encodeURIComponent(tempPath)}&type=dish` }) } }) }, + /** + * 处理菜品图片编辑后的回调 + */ + handleDishImageEdited(editedImagePath) { + const images = this.data.formData.images + images.push({ + tempPath: editedImagePath, + order: images.length + }) + this.setData({ + 'formData.images': images + }) + }, + /** * 删除图片 */ @@ -926,8 +952,9 @@ Page({ const token = app.globalData.token // 逐个上传步骤图片 - const uploadPromises = stepImages.map(item => { + const uploadPromises = stepImages.map((item, index) => { return new Promise((resolve, reject) => { + console.log(`开始上传步骤图片 ${item.stepIndex + 1}:`, item.tempPath) wx.uploadFile({ url: `${baseUrl}/api/dishes/dishes/${dishId}/steps/${item.stepIndex + 1}/`, // step_number 从1开始 filePath: item.tempPath, @@ -936,16 +963,30 @@ Page({ 'Authorization': `Bearer ${token}` }, success: (res) => { + console.log(`步骤图片上传响应 ${item.stepIndex + 1}:`, res.statusCode, res.data) if (res.statusCode === 200 || res.statusCode === 201) { - resolve(JSON.parse(res.data)) + try { + const data = JSON.parse(res.data) + console.log(`步骤图片上传成功 ${item.stepIndex + 1}:`, data) + resolve(data) + } catch (e) { + console.error(`解析响应数据失败 ${item.stepIndex + 1}:`, e, res.data) + reject(new Error('服务器返回数据格式错误')) + } } else { - const errorData = res.data ? JSON.parse(res.data) : {} - reject(new Error(errorData.error || '上传失败')) + try { + const errorData = res.data ? JSON.parse(res.data) : {} + console.error(`步骤图片上传失败 ${item.stepIndex + 1}:`, res.statusCode, errorData) + reject(new Error(errorData.error || `上传失败,状态码: ${res.statusCode}`)) + } catch (e) { + console.error(`步骤图片上传失败 ${item.stepIndex + 1}:`, res.statusCode, res.data) + reject(new Error(`上传失败,状态码: ${res.statusCode}`)) + } } }, fail: (err) => { - console.error('上传步骤图片失败:', err) - reject(err) + console.error(`步骤图片上传失败 ${item.stepIndex + 1}:`, err) + reject(new Error(err.errMsg || '网络错误,请检查网络连接')) } }) }) @@ -1000,6 +1041,7 @@ Page({ uploadImagesToDish(dishId, tempImages) { const uploadPromises = tempImages.map((imageData, index) => { return new Promise((resolve, reject) => { + console.log(`开始上传菜品图片 ${index}:`, imageData.tempPath) wx.uploadFile({ url: `${app.globalData.baseUrl}/api/chef/dishes/${dishId}/upload_image/`, filePath: imageData.tempPath, @@ -1011,14 +1053,31 @@ Page({ 'Authorization': `Bearer ${app.globalData.token}` }, success: (res) => { + console.log(`菜品图片上传响应 ${index}:`, res.statusCode, res.data) if (res.statusCode === 201) { - const data = JSON.parse(res.data) - resolve(data) + try { + const data = JSON.parse(res.data) + console.log(`菜品图片上传成功 ${index}:`, data) + resolve(data) + } catch (e) { + console.error(`解析响应数据失败 ${index}:`, e, res.data) + reject(new Error('服务器返回数据格式错误')) + } } else { - reject(new Error('上传失败')) + try { + const errorData = JSON.parse(res.data) + console.error(`菜品图片上传失败 ${index}:`, res.statusCode, errorData) + reject(new Error(errorData.error || `上传失败,状态码: ${res.statusCode}`)) + } catch (e) { + console.error(`菜品图片上传失败 ${index}:`, res.statusCode, res.data) + reject(new Error(`上传失败,状态码: ${res.statusCode}`)) + } } }, - fail: reject + fail: (err) => { + console.error(`菜品图片上传失败 ${index}:`, err) + reject(new Error(err.errMsg || '网络错误,请检查网络连接')) + } }) }) }) diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml index 00e36d9..b777950 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml @@ -14,6 +14,7 @@ class="dish-image" src="{{item.image_url}}" mode="aspectFill" + lazy-load="{{true}}" bindtap="previewImage" binderror="onImageError" bindload="onImageLoad" diff --git a/miniprogram/pages/home/home.wxml b/miniprogram/pages/home/home.wxml index 64e7aef..3d0ed98 100644 --- a/miniprogram/pages/home/home.wxml +++ b/miniprogram/pages/home/home.wxml @@ -13,6 +13,7 @@ { + wx.navigateBack() + }, 1500) + } + }, + + /** + * 加载图片信息 + */ + loadImage(src) { + console.log('开始加载图片:', src) + + wx.getImageInfo({ + src: src, + success: (res) => { + console.log('图片信息:', res) + this.calculateImageLayout(res) + }, + fail: (err) => { + console.error('获取图片信息失败:', err) + showError('图片加载失败') + setTimeout(() => { + wx.navigateBack() + }, 1500) + } + }) + }, + + /** + * 计算图片布局 + */ + calculateImageLayout(imageInfo) { + const { width, height } = imageInfo + const { cropWidth, cropHeight, rotation } = this.data + + // 获取屏幕信息 + const systemInfo = wx.getSystemInfoSync() + const screenWidth = systemInfo.windowWidth + const screenHeight = systemInfo.windowHeight + + // 考虑旋转后的图片尺寸 + let displayWidth, displayHeight + if (rotation === 90 || rotation === 270) { + // 旋转90或270度后,宽高互换 + displayWidth = height + displayHeight = width + } else { + displayWidth = width + displayHeight = height + } + + // 计算裁剪框在屏幕上的显示尺寸(保持比例) + const cropRatio = cropWidth / cropHeight + let cropDisplayWidth, cropDisplayHeight + if (screenWidth / screenHeight > cropRatio) { + // 屏幕更宽,以高度为基准 + cropDisplayHeight = screenHeight * 0.7 // 占用70%的屏幕高度 + cropDisplayWidth = cropDisplayHeight * cropRatio + } else { + // 屏幕更高,以宽度为基准 + cropDisplayWidth = screenWidth * 0.9 // 占用90%的屏幕宽度 + cropDisplayHeight = cropDisplayWidth / cropRatio + } + + // 确保裁剪框不会太大 + if (cropDisplayWidth > screenWidth * 0.9) { + cropDisplayWidth = screenWidth * 0.9 + cropDisplayHeight = cropDisplayWidth / cropRatio + } + if (cropDisplayHeight > screenHeight * 0.7) { + cropDisplayHeight = screenHeight * 0.7 + cropDisplayWidth = cropDisplayHeight * cropRatio + } + + // 计算图片的显示尺寸(需要覆盖裁剪框) + const imageRatio = displayWidth / displayHeight + const cropDisplayRatio = cropDisplayWidth / cropDisplayHeight + + let imageDisplayWidth, imageDisplayHeight + if (imageRatio > cropDisplayRatio) { + // 图片更宽,以高度为基准 + imageDisplayHeight = cropDisplayHeight * 1.5 // 确保能覆盖裁剪框 + imageDisplayWidth = imageDisplayHeight * imageRatio + } else { + // 图片更高,以宽度为基准 + imageDisplayWidth = cropDisplayWidth * 1.5 + imageDisplayHeight = imageDisplayWidth / imageRatio + } + + // 确保图片至少能覆盖裁剪框 + if (imageDisplayWidth < cropDisplayWidth) { + imageDisplayWidth = cropDisplayWidth + imageDisplayHeight = imageDisplayWidth / imageRatio + } + if (imageDisplayHeight < cropDisplayHeight) { + imageDisplayHeight = cropDisplayHeight + imageDisplayWidth = imageDisplayHeight * imageRatio + } + + // 计算图片位置(居中显示) + const imageTop = (screenHeight - imageDisplayHeight) / 2 + const imageLeft = (screenWidth - imageDisplayWidth) / 2 + + // 计算裁剪框在屏幕上的位置(居中) + const cropTop = (screenHeight - cropDisplayHeight) / 2 + const cropLeft = (screenWidth - cropDisplayWidth) / 2 + + // 计算最小缩放比例 + const minScale = Math.max( + cropDisplayWidth / imageDisplayWidth, + cropDisplayHeight / imageDisplayHeight, + 0.5 + ) + + this.setData({ + originalWidth: width, + originalHeight: height, + imageWidth: imageDisplayWidth, + imageHeight: imageDisplayHeight, + imageTop: imageTop, + imageLeft: imageLeft, + cropDisplayWidth: cropDisplayWidth, + cropDisplayHeight: cropDisplayHeight, + cropTop: cropTop, + cropLeft: cropLeft, + scale: 1, + minScale: minScale, + maxScale: 3, + translateX: 0, + translateY: 0 + }) + + console.log('图片布局计算完成:', { + 原始尺寸: { width, height }, + 显示尺寸: { imageDisplayWidth, imageDisplayHeight }, + 裁剪框显示尺寸: { cropDisplayWidth, cropDisplayHeight }, + 屏幕尺寸: { screenWidth, screenHeight } + }) + }, + + /** + * 触摸开始 + */ + onTouchStart(e) { + const touches = e.touches + + if (touches.length === 1) { + // 单指拖拽 + const touch = touches[0] + this.setData({ + startX: touch.clientX, + startY: touch.clientY, + isMoving: true, + isScaling: false + }) + } else if (touches.length === 2) { + // 双指缩放 + const distance = this.getDistance(touches[0], touches[1]) + this.setData({ + startDistance: distance, + startScale: this.data.scale, + isMoving: false, + isScaling: true + }) + } + }, + + /** + * 触摸移动 + */ + onTouchMove(e) { + const touches = e.touches + + if (touches.length === 1 && this.data.isMoving) { + // 单指拖拽 + const touch = touches[0] + const deltaX = touch.clientX - this.data.startX + const deltaY = touch.clientY - this.data.startY + + this.setData({ + translateX: this.data.translateX + deltaX, + translateY: this.data.translateY + deltaY, + startX: touch.clientX, + startY: touch.clientY + }) + } else if (touches.length === 2 && this.data.isScaling) { + // 双指缩放 + const distance = this.getDistance(touches[0], touches[1]) + const scale = (distance / this.data.startDistance) * this.data.startScale + + // 限制缩放范围 + const clampedScale = Math.max(this.data.minScale, Math.min(this.data.maxScale, scale)) + + this.setData({ + scale: clampedScale + }) + } + }, + + /** + * 触摸结束 + */ + onTouchEnd(e) { + this.setData({ + isMoving: false, + isScaling: false + }) + }, + + /** + * 计算两点间距离 + */ + getDistance(touch1, touch2) { + const dx = touch1.clientX - touch2.clientX + const dy = touch1.clientY - touch2.clientY + return Math.sqrt(dx * dx + dy * dy) + }, + + /** + * 旋转图片 + */ + rotateImage() { + const rotation = (this.data.rotation + 90) % 360 + this.setData({ + rotation, + translateX: 0, // 重置位移 + translateY: 0, + scale: 1 // 重置缩放 + }) + + // 重新计算布局(考虑旋转后的尺寸变化) + wx.getImageInfo({ + src: this.data.src, + success: (res) => { + this.calculateImageLayout(res) + } + }) + }, + + /** + * 跳过编辑,直接使用原图 + */ + skipEdit() { + // 直接返回原图路径,让后端处理成固定大小 + const pages = getCurrentPages() + const prevPage = pages[pages.length - 2] + if (prevPage) { + if (this.data.type === 'step') { + // 步骤图片 + if (prevPage.handleStepImageEdited) { + prevPage.handleStepImageEdited(this.data.src, this.data.stepIndex) + } + } else { + // 菜品图片 + if (prevPage.handleDishImageEdited) { + prevPage.handleDishImageEdited(this.data.src) + } + } + } + wx.navigateBack() + }, + + /** + * 确认编辑 + */ + confirmEdit() { + console.log('开始处理图片') + showLoading('处理中...') + + const { src, originalWidth, originalHeight, imageWidth, imageHeight, imageTop, imageLeft, + cropWidth, cropHeight, cropDisplayWidth, cropDisplayHeight, cropTop, cropLeft, + scale, translateX, translateY, rotation, type } = this.data + + // 创建画布上下文用于最终输出 + const finalCtx = wx.createCanvasContext('finalCanvas', this) + + // 计算裁剪区域在原始图片上的位置 + // 参考头像裁切的算法,但需要处理旋转 + + // 关键点:CSS transform rotate 不会改变DOM元素的宽高 + // 但布局计算时可能已经考虑了旋转后的视觉尺寸 + // 需要基于原始图片的实际尺寸来计算 + + // 获取屏幕尺寸(用于计算旋转后的坐标转换) + const screenWidth = wx.getSystemInfoSync().windowWidth + const screenHeight = wx.getSystemInfoSync().windowHeight + + // 计算图片中心点(在屏幕上的位置) + const imageCenterX = imageLeft + imageWidth / 2 + const imageCenterY = imageTop + imageHeight / 2 + + // 计算裁剪框中心点(在屏幕上的位置) + const cropCenterX = cropLeft + cropDisplayWidth / 2 + const cropCenterY = cropTop + cropDisplayHeight / 2 + + // 将裁剪框中心点相对于图片中心点的偏移(考虑用户缩放和位移) + // 注意:这里需要考虑旋转的影响 + let offsetX = cropCenterX - imageCenterX - translateX + let offsetY = cropCenterY - imageCenterY - translateY + + // 如果旋转了,需要将偏移转换回原始图片坐标系 + // 旋转是围绕图片中心点的 + if (rotation === 90) { + // 顺时针旋转90度:屏幕坐标(x,y) -> 原始图片坐标(-y, x) + const temp = offsetX + offsetX = -offsetY + offsetY = temp + } else if (rotation === 180) { + // 旋转180度:屏幕坐标(x,y) -> 原始图片坐标(-x, -y) + offsetX = -offsetX + offsetY = -offsetY + } else if (rotation === 270) { + // 顺时针旋转270度:屏幕坐标(x,y) -> 原始图片坐标(y, -x) + const temp = offsetX + offsetX = offsetY + offsetY = -temp + } + + // 将偏移转换为原始图片坐标(考虑缩放) + // offsetX/offsetY 是相对于图片中心的偏移(在显示尺寸上的) + // 需要除以scale得到在基准显示尺寸上的偏移 + // 再乘以显示到原始的比例得到在原始图片上的偏移 + const displayToOriginalRatio = originalWidth / imageWidth + const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatio + const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatio + + // 计算裁剪区域的尺寸(在原始图片上) + let sourceWidth = (cropDisplayWidth / scale) * displayToOriginalRatio + let sourceHeight = (cropDisplayHeight / scale) * displayToOriginalRatio + + // 计算裁剪区域的起始位置(在原始图片上) + // 原始图片中心点 + 偏移 - 裁剪区域尺寸的一半 + let sourceX = originalWidth / 2 + offsetInOriginalX - sourceWidth / 2 + let sourceY = originalHeight / 2 + offsetInOriginalY - sourceHeight / 2 + + // 确保裁剪区域在图片范围内 + sourceX = Math.max(0, Math.min(sourceX, originalWidth - sourceWidth)) + sourceY = Math.max(0, Math.min(sourceY, originalHeight - sourceHeight)) + sourceWidth = Math.min(sourceWidth, originalWidth - sourceX) + sourceHeight = Math.min(sourceHeight, originalHeight - sourceY) + + console.log('裁剪参数:', { + 屏幕尺寸: { screenWidth, screenHeight }, + 裁剪框位置: { cropLeft, cropTop, cropDisplayWidth, cropDisplayHeight }, + 图片中心: { imageCenterX, imageCenterY }, + 裁剪框中心: { cropCenterX, cropCenterY }, + 原始图片尺寸: { originalWidth, originalHeight }, + 基准显示尺寸: { imageWidth, imageHeight }, + 基准显示位置: { imageTop, imageLeft }, + 用户缩放: scale, + 用户位移: { translateX, translateY }, + 偏移: { offsetX, offsetY }, + 原始偏移: { offsetInOriginalX, offsetInOriginalY }, + 显示到原始比例: displayToOriginalRatio, + 转换后裁剪区域: { sourceX, sourceY, sourceWidth, sourceHeight }, + 目标尺寸: { cropWidth, cropHeight }, + 旋转: rotation + }) + + // 绘制裁剪后的图片 + // 如果旋转了,需要在canvas上旋转绘制,但裁剪区域已经是原始图片坐标 + if (rotation === 0) { + // 不旋转,直接裁剪 + finalCtx.drawImage( + src, + sourceX, sourceY, sourceWidth, sourceHeight, + 0, 0, cropWidth, cropHeight + ) + } else { + // 需要旋转:先裁剪原图区域,然后在最终画布上旋转 + // 注意:这里sourceX, sourceY已经是原始图片的坐标了 + // 在画布中心旋转并绘制裁剪区域 + finalCtx.translate(cropWidth / 2, cropHeight / 2) + finalCtx.rotate(rotation * Math.PI / 180) + finalCtx.drawImage( + src, + sourceX, sourceY, sourceWidth, sourceHeight, + -cropWidth / 2, -cropHeight / 2, cropWidth, cropHeight + ) + } + + finalCtx.draw(false, () => { + this.exportCanvas() + }) + }, + + /** + * 导出画布 + */ + exportCanvas() { + const { cropWidth, cropHeight } = this.data + + wx.canvasToTempFilePath({ + canvasId: 'finalCanvas', + fileType: 'jpg', // 使用JPEG格式,文件更小 + quality: 0.85, // 压缩质量 0-1,0.85是较好的平衡 + destWidth: cropWidth, // 指定输出宽度 + destHeight: cropHeight, // 指定输出高度 + success: (res) => { + console.log('图片处理成功:', res.tempFilePath) + + // 进一步压缩图片(如果文件仍然很大) + wx.getFileInfo({ + filePath: res.tempFilePath, + success: (fileInfo) => { + console.log('导出图片大小:', fileInfo.size, '字节') + + // 如果文件大于1MB,再次压缩 + if (fileInfo.size > 1024 * 1024) { + console.log('图片较大,进行二次压缩') + wx.compressImage({ + src: res.tempFilePath, + quality: 80, // 压缩质量 + success: (compressRes) => { + console.log('图片压缩成功:', compressRes.tempFilePath) + this.returnEditedImage(compressRes.tempFilePath) + }, + fail: (err) => { + console.error('图片压缩失败:', err) + // 压缩失败,使用原图 + this.returnEditedImage(res.tempFilePath) + } + }) + } else { + this.returnEditedImage(res.tempFilePath) + } + }, + fail: () => { + // 获取文件信息失败,直接返回 + this.returnEditedImage(res.tempFilePath) + } + }) + }, + fail: (err) => { + console.error('图片处理失败:', err) + hideLoading() + showError('图片处理失败') + } + }, this) + }, + + /** + * 返回编辑后的图片 + */ + returnEditedImage(imagePath) { + hideLoading() + + // 返回处理后的图片路径 + const pages = getCurrentPages() + const prevPage = pages[pages.length - 2] + if (prevPage) { + if (this.data.type === 'step') { + // 步骤图片 + if (prevPage.handleStepImageEdited) { + prevPage.handleStepImageEdited(imagePath, this.data.stepIndex) + } + } else { + // 菜品图片 + if (prevPage.handleDishImageEdited) { + prevPage.handleDishImageEdited(imagePath) + } + } + } + + wx.navigateBack() + }, + + /** + * 取消编辑 + */ + cancelEdit() { + wx.navigateBack() + } +}) + diff --git a/miniprogram/pages/image-edit/image-edit.json b/miniprogram/pages/image-edit/image-edit.json new file mode 100644 index 0000000..c50b175 --- /dev/null +++ b/miniprogram/pages/image-edit/image-edit.json @@ -0,0 +1,7 @@ +{ + "navigationBarTitleText": "编辑图片", + "navigationBarBackgroundColor": "#000000", + "navigationBarTextStyle": "white", + "backgroundColor": "#000000" +} + diff --git a/miniprogram/pages/image-edit/image-edit.wxml b/miniprogram/pages/image-edit/image-edit.wxml new file mode 100644 index 0000000..b17dc33 --- /dev/null +++ b/miniprogram/pages/image-edit/image-edit.wxml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + 取消 + 跳过 + 旋转 + 完成 + + + + + + + diff --git a/miniprogram/pages/image-edit/image-edit.wxss b/miniprogram/pages/image-edit/image-edit.wxss new file mode 100644 index 0000000..12b2711 --- /dev/null +++ b/miniprogram/pages/image-edit/image-edit.wxss @@ -0,0 +1,84 @@ +/* 图片编辑页面样式 */ +.edit-container { + position: relative; + width: 100%; + height: 100vh; + background: #000; + overflow: hidden; +} + +/* 图片显示区域 */ +.image-container { + position: relative; + width: 100%; + height: 100%; +} + +.edit-image { + position: absolute; + transition: transform 0.1s ease; + transform-origin: center center; +} + +/* 裁剪框遮罩 */ +.crop-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +} + +.crop-frame { + position: absolute; + border: 2px solid #1890ff; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); +} + +/* 底部操作栏 */ +.bottom-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 120rpx; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 40rpx; + background: rgba(0, 0, 0, 0.8); + z-index: 1000; +} + +.action-btn { + font-size: 36rpx; + padding: 20rpx 40rpx; + border-radius: 8rpx; +} + +.action-btn.cancel { + color: #999; +} + +.action-btn.skip { + color: #fff; +} + +.action-btn.rotate { + color: #fff; +} + +.action-btn.confirm { + color: #1890ff; + font-weight: bold; +} + +/* 隐藏的画布 */ +.hidden-canvas { + position: absolute; + top: -9999px; + left: -9999px; + visibility: hidden; +} + diff --git a/miniprogram/pages/menu/menu.wxml b/miniprogram/pages/menu/menu.wxml index 88f7cab..99fb155 100644 --- a/miniprogram/pages/menu/menu.wxml +++ b/miniprogram/pages/menu/menu.wxml @@ -64,6 +64,7 @@ diff --git "a/\345\233\276\347\211\207\344\274\230\345\214\226\345\256\236\346\226\275\346\200\273\347\273\223.md" "b/\345\233\276\347\211\207\344\274\230\345\214\226\345\256\236\346\226\275\346\200\273\347\273\223.md" new file mode 100644 index 0000000..f219f4c --- /dev/null +++ "b/\345\233\276\347\211\207\344\274\230\345\214\226\345\256\236\346\226\275\346\200\273\347\273\223.md" @@ -0,0 +1,114 @@ +# 图片加载优化实施总结 + +## 已完成的优化 + +### 1. 后端图片处理 ✅ + +#### 1.1 创建图片处理工具类 +**文件**: `backend/meal_architect/utils/image_processor.py` + +- `compress_image()`: 压缩图片(最大1920x1080,质量85%) +- `generate_thumbnail()`: 生成缩略图(400x300,质量80%) +- `process_dish_image()`: 处理菜品图片(压缩+缩略图) +- `process_avatar_image()`: 处理头像(压缩到800x800) +- `process_step_image()`: 处理制作步骤图片(压缩到1200x900) + +#### 1.2 修改数据库模型 +**文件**: `backend/dishes/models.py` + +- 在 `DishImage` 模型中添加 `thumbnail` 字段 +- 添加 `dish_thumbnail_upload_path()` 函数用于缩略图存储路径 + +#### 1.3 修改视图处理 +**文件**: `backend/dishes/views.py` + +- `create()`: 创建菜品时自动压缩图片并生成缩略图 +- `upload_image()`: 上传菜品图片时自动压缩并生成缩略图 +- `upload_step_image()`: 上传制作步骤图片时自动压缩 + +**文件**: `backend/users/views.py` + +- `upload_avatar()`: 上传头像时自动压缩(800x800) + +#### 1.4 修改序列化器 +**文件**: `backend/dishes/serializers.py` + +- `DishImageSerializer`: 添加 `thumbnail_url` 字段返回缩略图URL +- `DishListSerializer.get_main_image()`: 优先返回缩略图URL(列表页使用) + +### 2. 小程序端优化 ✅ + +#### 2.1 添加懒加载 +- `miniprogram/pages/home/home.wxml`: 列表页图片添加 `lazy-load` +- `miniprogram/pages/menu/menu.wxml`: 菜单页图片添加 `lazy-load` +- `miniprogram/pages/gourmet/dish-detail/dish-detail.wxml`: 详情页图片添加 `lazy-load` + +#### 2.2 图片加载策略 +- 列表页自动使用缩略图(后端返回) +- 详情页使用原图(已压缩) +- 头像使用压缩后的图片(800x800) + +## 数据库迁移 + +**文件**: `backend/dishes/migrations/0001_initial_thumbnail.py` + +需要执行迁移: +```bash +cd backend +python manage.py migrate dishes +``` + +## 优化效果预期 + +### 优化前 +- 图片大小: 2-10MB(手机拍摄) +- 列表页加载10张图片: 30-60秒 +- 单张图片加载: 5-15秒 +- 用户体验: 很差 + +### 优化后 +- 原图大小: 200-500KB(压缩后) +- 缩略图大小: 50-100KB +- 列表页加载10张缩略图: 2-3秒 +- 单张缩略图加载: 0.2-0.5秒 +- 单张原图加载: 1-2秒 +- 用户体验: 良好 + +## 注意事项 + +### 1. 现有图片处理 +- 已上传的图片没有压缩和缩略图 +- 需要时可编写脚本批量处理旧图片 + +### 2. 存储空间 +- 缩略图会增加存储空间,但影响很小(每张约50-100KB) +- 原图压缩后节省大量空间 + +### 3. 性能影响 +- 图片压缩会增加服务器CPU使用 +- 对于单用户,影响可忽略 +- 上传时会有轻微延迟(压缩处理时间) + +### 4. 兼容性 +- 新上传的图片自动处理 +- 旧图片仍然可以正常显示(没有缩略图时会使用原图) + +## 技术细节 + +### 图片压缩参数 +- **菜品原图**: 最大1920x1080,质量85% +- **缩略图**: 400x300,质量80% +- **头像**: 最大800x800,质量85% +- **步骤图**: 最大1200x900,质量85% + +### 格式转换 +- 所有图片统一转换为JPEG格式 +- 自动处理透明通道(转换为白色背景) + +## 后续可选优化 + +1. **WebP格式支持**: 进一步减少图片大小(需要评估小程序兼容性) +2. **批量处理旧图片**: 编写脚本批量压缩和生成缩略图 +3. **CDN加速**: 使用CDN加速图片加载(需要配置) +4. **渐进式加载**: 先显示模糊缩略图,再加载清晰原图 + -- Gitee From 46af6bc81a7a13405b0e2cfccb997347e3619cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Wed, 5 Nov 2025 12:06:27 +0800 Subject: [PATCH 26/44] =?UTF-8?q?=E8=8F=9C=E5=93=81=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E6=B7=BB=E5=8A=A0=E5=9B=BE=E7=89=87=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/pages/chef/dish-edit/dish-edit.js | 47 +++++++++++++++++++ .../pages/chef/dish-edit/dish-edit.wxml | 10 +++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.js b/miniprogram/pages/chef/dish-edit/dish-edit.js index ced3116..2a83634 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.js +++ b/miniprogram/pages/chef/dish-edit/dish-edit.js @@ -1172,6 +1172,53 @@ Page({ 'formData.images': images }) } + }, + + /** + * 预览菜品图片 + */ + previewDishImage(e) { + const index = e.currentTarget.dataset.index + const currentUrl = e.currentTarget.dataset.url + const images = this.data.formData.images + + // 收集所有有效的图片URL(包括临时路径) + const urls = images + .map(img => img.image_url || img.tempPath) + .filter(url => url && url !== '/images/default-dish.png') + + if (urls.length === 0) { + showError('没有可预览的图片') + return + } + + // 找到当前图片在列表中的位置 + let currentIndex = urls.indexOf(currentUrl) + if (currentIndex === -1) { + currentIndex = 0 + } + + wx.previewImage({ + current: urls[currentIndex] || urls[0], + urls: urls + }) + }, + + /** + * 预览步骤图片 + */ + previewStepImage(e) { + const url = e.currentTarget.dataset.url + + if (!url) { + showError('图片不存在') + return + } + + wx.previewImage({ + current: url, + urls: [url] + }) } }) diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.wxml b/miniprogram/pages/chef/dish-edit/dish-edit.wxml index 0d8327b..01bcd60 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.wxml +++ b/miniprogram/pages/chef/dish-edit/dish-edit.wxml @@ -41,7 +41,9 @@ mode="aspectFill" binderror="onImageError" bindload="onImageLoad" - data-index="{{index}}"> + bindtap="previewDishImage" + data-index="{{index}}" + data-url="{{item.image_url || item.tempPath}}"> × @@ -101,7 +103,11 @@ - + × -- Gitee From a984021214b41180662198df1e6929428826a63b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Wed, 5 Nov 2025 14:09:19 +0800 Subject: [PATCH 27/44] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=8F=9C=E5=93=81?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2=EF=BC=8C=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/gourmet/dish-detail/dish-detail.js | 40 ++++++++++++++++++- .../gourmet/dish-detail/dish-detail.wxml | 2 +- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.js b/miniprogram/pages/gourmet/dish-detail/dish-detail.js index 1e486ef..fc3d7d4 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.js +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.js @@ -183,7 +183,7 @@ Page({ }) }, - // 预览图片 + // 预览图片(菜品图片) previewImage(e) { const current = e.currentTarget.dataset.url const urls = this.data.dish.images ? this.data.dish.images.map(img => img.image_url) : [] @@ -194,6 +194,44 @@ Page({ }) }, + // 预览步骤图片 + previewStepImage(e) { + // 阻止事件冒泡 + if (e && e.stopPropagation) { + e.stopPropagation() + } + + const url = e.currentTarget.dataset.url + + if (!url) { + wx.showToast({ + title: '图片不存在', + icon: 'none' + }) + return + } + + // 收集所有步骤图片URL + const stepImages = this.data.dish.cooking_steps || [] + const allUrls = stepImages + .map(step => step.image_url) + .filter(imgUrl => imgUrl) // 过滤空值 + + // 找到当前图片在列表中的位置 + let currentIndex = allUrls.indexOf(url) + if (currentIndex === -1) { + currentIndex = 0 + } + + // 将当前图片放在第一位 + const urls = [url, ...allUrls.filter(u => u !== url)] + + wx.previewImage({ + current: url, + urls: urls + }) + }, + // 图片轮播变化 onSwiperChange(e) { this.setData({ diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml index b777950..9ba4cd8 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml @@ -100,7 +100,7 @@ class="step-img" src="{{item.image_url}}" mode="aspectFill" - bindtap="previewImage" + catchtap="previewStepImage" data-url="{{item.image_url}}" /> -- Gitee From 43d5a551042d7d0b3382de915dfd791fa1d482b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Wed, 12 Nov 2025 11:12:22 +0800 Subject: [PATCH 28/44] test --- miniprogram/pages/image-edit/image-edit.js | 142 ++++++++++++--------- 1 file changed, 80 insertions(+), 62 deletions(-) diff --git a/miniprogram/pages/image-edit/image-edit.js b/miniprogram/pages/image-edit/image-edit.js index ee13a0b..e71fff1 100644 --- a/miniprogram/pages/image-edit/image-edit.js +++ b/miniprogram/pages/image-edit/image-edit.js @@ -33,7 +33,7 @@ Page({ }, onLoad(options) { - console.log('图片编辑页面加载,参数:', options) + console.log('图片编辑页面加载,参数:', JSON.stringify(options, null, 2)) if (options.src) { const src = decodeURIComponent(options.src) @@ -77,11 +77,11 @@ Page({ wx.getImageInfo({ src: src, success: (res) => { - console.log('图片信息:', res) + console.log('图片信息:', JSON.stringify(res, null, 2)) this.calculateImageLayout(res) }, fail: (err) => { - console.error('获取图片信息失败:', err) + console.error('获取图片信息失败:', JSON.stringify(err, null, 2)) showError('图片加载失败') setTimeout(() => { wx.navigateBack() @@ -95,23 +95,16 @@ Page({ */ calculateImageLayout(imageInfo) { const { width, height } = imageInfo - const { cropWidth, cropHeight, rotation } = this.data + const { cropWidth, cropHeight } = this.data // 获取屏幕信息 const systemInfo = wx.getSystemInfoSync() const screenWidth = systemInfo.windowWidth const screenHeight = systemInfo.windowHeight - // 考虑旋转后的图片尺寸 - let displayWidth, displayHeight - if (rotation === 90 || rotation === 270) { - // 旋转90或270度后,宽高互换 - displayWidth = height - displayHeight = width - } else { - displayWidth = width - displayHeight = height - } + // 图片的宽高比是固定的,旋转只是CSS变换,不影响布局计算 + // 始终使用原始图片的宽高比 + const imageRatio = width / height // 计算裁剪框在屏幕上的显示尺寸(保持比例) const cropRatio = cropWidth / cropHeight @@ -136,45 +129,30 @@ Page({ cropDisplayWidth = cropDisplayHeight * cropRatio } - // 计算图片的显示尺寸(需要覆盖裁剪框) - const imageRatio = displayWidth / displayHeight - const cropDisplayRatio = cropDisplayWidth / cropDisplayHeight - - let imageDisplayWidth, imageDisplayHeight - if (imageRatio > cropDisplayRatio) { - // 图片更宽,以高度为基准 - imageDisplayHeight = cropDisplayHeight * 1.5 // 确保能覆盖裁剪框 - imageDisplayWidth = imageDisplayHeight * imageRatio - } else { - // 图片更高,以宽度为基准 - imageDisplayWidth = cropDisplayWidth * 1.5 - imageDisplayHeight = imageDisplayWidth / imageRatio - } - - // 确保图片至少能覆盖裁剪框 - if (imageDisplayWidth < cropDisplayWidth) { - imageDisplayWidth = cropDisplayWidth - imageDisplayHeight = imageDisplayWidth / imageRatio - } - if (imageDisplayHeight < cropDisplayHeight) { - imageDisplayHeight = cropDisplayHeight - imageDisplayWidth = imageDisplayHeight * imageRatio - } - - // 计算图片位置(居中显示) - const imageTop = (screenHeight - imageDisplayHeight) / 2 - const imageLeft = (screenWidth - imageDisplayWidth) / 2 + // 计算图片的显示尺寸 + // 需求:图片的显示宽度始终等于裁切框的显示宽度,高度按原始宽高比缩放 + // 不管是否旋转,CSS的width都贴着裁切框显示 + const imageDisplayWidth = cropDisplayWidth + const imageDisplayHeight = imageDisplayWidth / imageRatio // 计算裁剪框在屏幕上的位置(居中) const cropTop = (screenHeight - cropDisplayHeight) / 2 const cropLeft = (screenWidth - cropDisplayWidth) / 2 + // 计算图片位置 + // 图片宽度等于裁切框宽度,左边贴着裁切框左边对齐 + const imageLeft = cropLeft + // 图片高度按比例缩放,垂直居中显示 + const imageTop = (screenHeight - imageDisplayHeight) / 2 + // 计算最小缩放比例 - const minScale = Math.max( - cropDisplayWidth / imageDisplayWidth, - cropDisplayHeight / imageDisplayHeight, - 0.5 - ) + // 图片宽度已经等于裁切框宽度,所以只需要考虑高度 + // 如果图片高度小于裁切框高度,需要放大才能覆盖 + const minScale = imageDisplayHeight < cropDisplayHeight + ? cropDisplayHeight / imageDisplayHeight + : 1.0 + // 最小缩放比例不能小于0.5 + const finalMinScale = Math.max(minScale, 0.5) this.setData({ originalWidth: width, @@ -188,18 +166,18 @@ Page({ cropTop: cropTop, cropLeft: cropLeft, scale: 1, - minScale: minScale, + minScale: finalMinScale, maxScale: 3, translateX: 0, translateY: 0 }) - console.log('图片布局计算完成:', { + console.log('图片布局计算完成:', JSON.stringify({ 原始尺寸: { width, height }, 显示尺寸: { imageDisplayWidth, imageDisplayHeight }, 裁剪框显示尺寸: { cropDisplayWidth, cropDisplayHeight }, 屏幕尺寸: { screenWidth, screenHeight } - }) + }, null, 2)) }, /** @@ -380,17 +358,50 @@ Page({ offsetY = -temp } - // 将偏移转换为原始图片坐标(考虑缩放) - // offsetX/offsetY 是相对于图片中心的偏移(在显示尺寸上的) + // 将偏移转换为原始图片坐标(考虑缩放和旋转) + // offsetX/offsetY 是相对于图片中心的偏移(在显示尺寸上的,已转换到原始图片坐标系) // 需要除以scale得到在基准显示尺寸上的偏移 // 再乘以显示到原始的比例得到在原始图片上的偏移 - const displayToOriginalRatio = originalWidth / imageWidth - const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatio - const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatio + + // 关键:imageWidth和imageHeight是DOM元素的实际宽高(CSS transform不会改变) + // 但在calculateImageLayout中,当旋转90/270度时,我们已经基于旋转后的视觉尺寸计算了这些值 + // 所以:当旋转90/270度时,imageWidth在视觉上对应originalHeight,imageHeight对应originalWidth + + // 根据旋转角度确定正确的比例关系 + let displayToOriginalRatioX, displayToOriginalRatioY + if (rotation === 90 || rotation === 270) { + // 旋转90/270度: + // imageWidth是屏幕上显示的宽度,视觉上对应原始图片的高度 + // imageHeight是屏幕上显示的高度,视觉上对应原始图片的宽度 + // 但offsetX/offsetY已经转换到原始图片坐标系,所以: + // offsetX对应原始图片的X方向(宽度方向),需要用originalWidth的比例 + // offsetY对应原始图片的Y方向(高度方向),需要用originalHeight的比例 + // 但是,由于旋转,imageWidth对应originalHeight,imageHeight对应originalWidth + displayToOriginalRatioX = originalWidth / imageHeight // offsetX用imageHeight的比例 + displayToOriginalRatioY = originalHeight / imageWidth // offsetY用imageWidth的比例 + } else { + // 不旋转或旋转180度:显示宽对应原始宽,显示高对应原始高 + displayToOriginalRatioX = originalWidth / imageWidth + displayToOriginalRatioY = originalHeight / imageHeight + } + + const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatioX + const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatioY // 计算裁剪区域的尺寸(在原始图片上) - let sourceWidth = (cropDisplayWidth / scale) * displayToOriginalRatio - let sourceHeight = (cropDisplayHeight / scale) * displayToOriginalRatio + // 注意:旋转90/270度时,裁剪框的宽高在原始图片上也需要互换 + let sourceWidth, sourceHeight + if (rotation === 90 || rotation === 270) { + // 旋转90/270度: + // cropDisplayWidth在屏幕上,视觉上对应原始图片的高度方向 + // cropDisplayHeight在屏幕上,视觉上对应原始图片的宽度方向 + // 所以:sourceWidth应该用cropDisplayHeight计算,sourceHeight应该用cropDisplayWidth计算 + sourceWidth = (cropDisplayHeight / scale) * displayToOriginalRatioX + sourceHeight = (cropDisplayWidth / scale) * displayToOriginalRatioY + } else { + sourceWidth = (cropDisplayWidth / scale) * displayToOriginalRatioX + sourceHeight = (cropDisplayHeight / scale) * displayToOriginalRatioY + } // 计算裁剪区域的起始位置(在原始图片上) // 原始图片中心点 + 偏移 - 裁剪区域尺寸的一半 @@ -403,7 +414,7 @@ Page({ sourceWidth = Math.min(sourceWidth, originalWidth - sourceX) sourceHeight = Math.min(sourceHeight, originalHeight - sourceY) - console.log('裁剪参数:', { + console.log('裁剪参数:', JSON.stringify({ 屏幕尺寸: { screenWidth, screenHeight }, 裁剪框位置: { cropLeft, cropTop, cropDisplayWidth, cropDisplayHeight }, 图片中心: { imageCenterX, imageCenterY }, @@ -415,11 +426,11 @@ Page({ 用户位移: { translateX, translateY }, 偏移: { offsetX, offsetY }, 原始偏移: { offsetInOriginalX, offsetInOriginalY }, - 显示到原始比例: displayToOriginalRatio, + 显示到原始比例: { X: displayToOriginalRatioX, Y: displayToOriginalRatioY }, 转换后裁剪区域: { sourceX, sourceY, sourceWidth, sourceHeight }, 目标尺寸: { cropWidth, cropHeight }, 旋转: rotation - }) + }, null, 2)) // 绘制裁剪后的图片 // 如果旋转了,需要在canvas上旋转绘制,但裁剪区域已经是原始图片坐标 @@ -433,14 +444,21 @@ Page({ } else { // 需要旋转:先裁剪原图区域,然后在最终画布上旋转 // 注意:这里sourceX, sourceY已经是原始图片的坐标了 + // 当旋转90/270度时,sourceWidth和sourceHeight已经互换了 // 在画布中心旋转并绘制裁剪区域 + finalCtx.save() finalCtx.translate(cropWidth / 2, cropHeight / 2) finalCtx.rotate(rotation * Math.PI / 180) + + // 绘制裁剪区域到canvas + // 注意:当旋转90/270度时,sourceWidth和sourceHeight已经互换了 + // 所以直接绘制sourceWidth x sourceHeight的区域,旋转后会自动变成正确的方向 finalCtx.drawImage( src, sourceX, sourceY, sourceWidth, sourceHeight, -cropWidth / 2, -cropHeight / 2, cropWidth, cropHeight ) + finalCtx.restore() } finalCtx.draw(false, () => { @@ -480,7 +498,7 @@ Page({ this.returnEditedImage(compressRes.tempFilePath) }, fail: (err) => { - console.error('图片压缩失败:', err) + console.error('图片压缩失败:', JSON.stringify(err, null, 2)) // 压缩失败,使用原图 this.returnEditedImage(res.tempFilePath) } @@ -496,7 +514,7 @@ Page({ }) }, fail: (err) => { - console.error('图片处理失败:', err) + console.error('图片处理失败:', JSON.stringify(err, null, 2)) hideLoading() showError('图片处理失败') } -- Gitee From 16c7aa04f9278a1e53da4891131c919d282e7d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Wed, 12 Nov 2025 14:05:22 +0800 Subject: [PATCH 29/44] =?UTF-8?q?=E7=AB=96=E7=9D=80=E7=9A=84=E7=85=A7?= =?UTF-8?q?=E7=89=87=E6=97=8B=E8=BD=AC=E5=90=8E=E8=A3=81=E5=88=87=E6=AD=A3?= =?UTF-8?q?=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/pages/image-edit/image-edit.js | 199 +++++++++++++++++---- 1 file changed, 160 insertions(+), 39 deletions(-) diff --git a/miniprogram/pages/image-edit/image-edit.js b/miniprogram/pages/image-edit/image-edit.js index e71fff1..36547f8 100644 --- a/miniprogram/pages/image-edit/image-edit.js +++ b/miniprogram/pages/image-edit/image-edit.js @@ -130,27 +130,33 @@ Page({ } // 计算图片的显示尺寸 - // 需求:图片的显示宽度始终等于裁切框的显示宽度,高度按原始宽高比缩放 - // 不管是否旋转,CSS的width都贴着裁切框显示 - const imageDisplayWidth = cropDisplayWidth - const imageDisplayHeight = imageDisplayWidth / imageRatio + // 需求:短边与裁剪框一致(不限高度还是宽度) + // 比较图片和裁剪框的宽高比,决定以哪一边为基准 + let imageDisplayWidth, imageDisplayHeight + if (imageRatio > cropRatio) { + // 图片更宽(横向),高度与裁剪框高度一致 + imageDisplayHeight = cropDisplayHeight + imageDisplayWidth = imageDisplayHeight * imageRatio + } else { + // 图片更高(纵向)或等比例,宽度与裁剪框宽度一致 + imageDisplayWidth = cropDisplayWidth + imageDisplayHeight = imageDisplayWidth / imageRatio + } // 计算裁剪框在屏幕上的位置(居中) const cropTop = (screenHeight - cropDisplayHeight) / 2 const cropLeft = (screenWidth - cropDisplayWidth) / 2 - // 计算图片位置 - // 图片宽度等于裁切框宽度,左边贴着裁切框左边对齐 - const imageLeft = cropLeft - // 图片高度按比例缩放,垂直居中显示 + // 计算图片位置(居中显示) + const imageLeft = (screenWidth - imageDisplayWidth) / 2 const imageTop = (screenHeight - imageDisplayHeight) / 2 // 计算最小缩放比例 - // 图片宽度已经等于裁切框宽度,所以只需要考虑高度 - // 如果图片高度小于裁切框高度,需要放大才能覆盖 - const minScale = imageDisplayHeight < cropDisplayHeight - ? cropDisplayHeight / imageDisplayHeight - : 1.0 + // 确保图片缩放后能够完全覆盖裁剪框 + // 需要同时考虑宽度和高度方向 + const scaleX = cropDisplayWidth / imageDisplayWidth + const scaleY = cropDisplayHeight / imageDisplayHeight + const minScale = Math.max(scaleX, scaleY, 1.0) // 至少为1.0,确保能覆盖裁剪框 // 最小缩放比例不能小于0.5 const finalMinScale = Math.max(minScale, 0.5) @@ -219,12 +225,28 @@ Page({ const deltaX = touch.clientX - this.data.startX const deltaY = touch.clientY - this.data.startY - this.setData({ - translateX: this.data.translateX + deltaX, - translateY: this.data.translateY + deltaY, - startX: touch.clientX, - startY: touch.clientY - }) + // 计算新的位移值 + let newTranslateX = this.data.translateX + deltaX + let newTranslateY = this.data.translateY + deltaY + + // 限制拖动范围,确保图片不超出裁剪框边界 + const bounds = this.calculateDragBounds() + + // 计算限制后的位移值 + const clampedX = Math.max(bounds.minX, Math.min(bounds.maxX, newTranslateX)) + const clampedY = Math.max(bounds.minY, Math.min(bounds.maxY, newTranslateY)) + + // 只有当位移值发生变化时才更新(避免不必要的setData) + // 同时检查是否有实际的移动意图(deltaX或deltaY不为0) + if (clampedX !== this.data.translateX || clampedY !== this.data.translateY || + Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5) { + this.setData({ + translateX: clampedX, + translateY: clampedY, + startX: touch.clientX, + startY: touch.clientY + }) + } } else if (touches.length === 2 && this.data.isScaling) { // 双指缩放 const distance = this.getDistance(touches[0], touches[1]) @@ -236,6 +258,18 @@ Page({ this.setData({ scale: clampedScale }) + + // 缩放后重新限制拖动范围 + const bounds = this.calculateDragBounds() + const currentTranslateX = Math.max(bounds.minX, Math.min(bounds.maxX, this.data.translateX)) + const currentTranslateY = Math.max(bounds.minY, Math.min(bounds.maxY, this.data.translateY)) + + if (currentTranslateX !== this.data.translateX || currentTranslateY !== this.data.translateY) { + this.setData({ + translateX: currentTranslateX, + translateY: currentTranslateY + }) + } } }, @@ -258,6 +292,75 @@ Page({ return Math.sqrt(dx * dx + dy * dy) }, + /** + * 计算拖动边界 + * 确保图片在缩放和位移后,不会超出裁剪框可视区域 + */ + calculateDragBounds() { + const { + imageWidth, imageHeight, imageLeft, imageTop, + cropDisplayWidth, cropDisplayHeight, cropLeft, cropTop, + scale, rotation + } = this.data + + // 计算缩放后的图片实际尺寸(考虑旋转后的视觉尺寸) + // 旋转90/270度时,视觉上的宽高会互换 + let visualWidth, visualHeight + if (rotation === 90 || rotation === 270) { + // 旋转90/270度:视觉宽度 = 实际高度,视觉高度 = 实际宽度 + visualWidth = imageHeight * scale + visualHeight = imageWidth * scale + } else { + // 不旋转或旋转180度:视觉尺寸 = 实际尺寸 + visualWidth = imageWidth * scale + visualHeight = imageHeight * scale + } + + // 计算图片中心点位置 + const imageCenterX = imageLeft + imageWidth / 2 + const imageCenterY = imageTop + imageHeight / 2 + + // 计算裁剪框中心点位置 + const cropCenterX = cropLeft + cropDisplayWidth / 2 + const cropCenterY = cropTop + cropDisplayHeight / 2 + + // 计算图片中心相对于裁剪框中心的最大允许偏移 + // 图片边缘不能超出裁剪框边缘(基于视觉尺寸) + // 如果视觉尺寸小于等于裁剪框,最大偏移为0(不允许移动) + // 使用一个很小的容差值(0.1px),避免浮点数精度问题 + const maxOffsetX = visualWidth > cropDisplayWidth + 0.1 + ? (visualWidth - cropDisplayWidth) / 2 + : 0 + const maxOffsetY = visualHeight > cropDisplayHeight + 0.1 + ? (visualHeight - cropDisplayHeight) / 2 + : 0 + + // 分别计算X和Y方向的边界 + // 如果某个方向的视觉尺寸小于等于裁剪框,则该方向不允许移动(保持居中) + // 如果大于裁剪框,则允许在该方向移动,但不能超出边界 + const centerOffsetX = cropCenterX - imageCenterX + const centerOffsetY = cropCenterY - imageCenterY + + const bounds = { + minX: centerOffsetX - maxOffsetX, + maxX: centerOffsetX + maxOffsetX, + minY: centerOffsetY - maxOffsetY, + maxY: centerOffsetY + maxOffsetY + } + + // 调试日志(仅在开发环境) + if (maxOffsetX === 0 && maxOffsetY === 0 && visualWidth > 0 && visualHeight > 0) { + console.log('拖动边界计算:', { + visualSize: { visualWidth, visualHeight }, + cropSize: { cropDisplayWidth, cropDisplayHeight }, + maxOffset: { maxOffsetX, maxOffsetY }, + bounds + }) + } + + return bounds + }, + /** * 旋转图片 */ @@ -335,27 +438,34 @@ Page({ const cropCenterX = cropLeft + cropDisplayWidth / 2 const cropCenterY = cropTop + cropDisplayHeight / 2 - // 将裁剪框中心点相对于图片中心点的偏移(考虑用户缩放和位移) - // 注意:这里需要考虑旋转的影响 - let offsetX = cropCenterX - imageCenterX - translateX - let offsetY = cropCenterY - imageCenterY - translateY + // 将裁剪框中心点相对于图片中心点的偏移(在屏幕坐标系下) + // 注意:translateX/translateY是屏幕坐标系下的位移 + let screenOffsetX = cropCenterX - imageCenterX - translateX + let screenOffsetY = cropCenterY - imageCenterY - translateY - // 如果旋转了,需要将偏移转换回原始图片坐标系 - // 旋转是围绕图片中心点的 + // 将屏幕坐标系的偏移转换为原始图片坐标系的偏移 + // 旋转是围绕图片中心点的,需要根据旋转角度转换坐标系 + let offsetX, offsetY if (rotation === 90) { - // 顺时针旋转90度:屏幕坐标(x,y) -> 原始图片坐标(-y, x) - const temp = offsetX - offsetX = -offsetY - offsetY = temp + // 顺时针旋转90度: + // 屏幕坐标系:X向右,Y向下 + // 原始图片坐标系:X向右,Y向下 + // 旋转90度后,屏幕上的X对应原始图片的-Y,屏幕上的Y对应原始图片的X + // 屏幕坐标(x,y) -> 原始图片坐标(-y, x) + offsetX = -screenOffsetY + offsetY = screenOffsetX } else if (rotation === 180) { // 旋转180度:屏幕坐标(x,y) -> 原始图片坐标(-x, -y) - offsetX = -offsetX - offsetY = -offsetY + offsetX = -screenOffsetX + offsetY = -screenOffsetY } else if (rotation === 270) { // 顺时针旋转270度:屏幕坐标(x,y) -> 原始图片坐标(y, -x) - const temp = offsetX - offsetX = offsetY - offsetY = -temp + offsetX = screenOffsetY + offsetY = -screenOffsetX + } else { + // 不旋转:屏幕坐标 = 原始图片坐标 + offsetX = screenOffsetX + offsetY = screenOffsetY } // 将偏移转换为原始图片坐标(考虑缩放和旋转) @@ -364,8 +474,8 @@ Page({ // 再乘以显示到原始的比例得到在原始图片上的偏移 // 关键:imageWidth和imageHeight是DOM元素的实际宽高(CSS transform不会改变) - // 但在calculateImageLayout中,当旋转90/270度时,我们已经基于旋转后的视觉尺寸计算了这些值 - // 所以:当旋转90/270度时,imageWidth在视觉上对应originalHeight,imageHeight对应originalWidth + // 在calculateImageLayout中,图片显示尺寸的短边与裁剪框一致(宽度或高度) + // 当旋转90/270度时,imageWidth在视觉上对应originalHeight,imageHeight对应originalWidth // 根据旋转角度确定正确的比例关系 let displayToOriginalRatioX, displayToOriginalRatioY @@ -396,8 +506,11 @@ Page({ // cropDisplayWidth在屏幕上,视觉上对应原始图片的高度方向 // cropDisplayHeight在屏幕上,视觉上对应原始图片的宽度方向 // 所以:sourceWidth应该用cropDisplayHeight计算,sourceHeight应该用cropDisplayWidth计算 - sourceWidth = (cropDisplayHeight / scale) * displayToOriginalRatioX - sourceHeight = (cropDisplayWidth / scale) * displayToOriginalRatioY + // 但是要注意:displayToOriginalRatioX对应原始图片的X方向(宽度),displayToOriginalRatioY对应原始图片的Y方向(高度) + // 旋转90度后,屏幕上的宽度对应原始图片的高度,所以用displayToOriginalRatioY + // 屏幕上的高度对应原始图片的宽度,所以用displayToOriginalRatioX + sourceWidth = (cropDisplayHeight / scale) * displayToOriginalRatioX // 屏幕高度 -> 原始宽度 + sourceHeight = (cropDisplayWidth / scale) * displayToOriginalRatioY // 屏幕宽度 -> 原始高度 } else { sourceWidth = (cropDisplayWidth / scale) * displayToOriginalRatioX sourceHeight = (cropDisplayHeight / scale) * displayToOriginalRatioY @@ -424,9 +537,17 @@ Page({ 基准显示位置: { imageTop, imageLeft }, 用户缩放: scale, 用户位移: { translateX, translateY }, - 偏移: { offsetX, offsetY }, + 屏幕偏移: { screenOffsetX, screenOffsetY }, + 转换后偏移: { offsetX, offsetY }, 原始偏移: { offsetInOriginalX, offsetInOriginalY }, 显示到原始比例: { X: displayToOriginalRatioX, Y: displayToOriginalRatioY }, + 裁剪区域尺寸计算: { + cropDisplayWidth: cropDisplayWidth, + cropDisplayHeight: cropDisplayHeight, + scale: scale, + ratioX: displayToOriginalRatioX, + ratioY: displayToOriginalRatioY + }, 转换后裁剪区域: { sourceX, sourceY, sourceWidth, sourceHeight }, 目标尺寸: { cropWidth, cropHeight }, 旋转: rotation -- Gitee From 0b0f81184f3a80a251bfb12a818889afade6cd46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Wed, 12 Nov 2025 15:09:09 +0800 Subject: [PATCH 30/44] =?UTF-8?q?=E6=97=8B=E8=BD=AC=E5=90=8E=E8=A3=81?= =?UTF-8?q?=E5=88=87=E6=AD=A3=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/pages/image-edit/image-edit.js | 100 +++++++++++---------- 1 file changed, 55 insertions(+), 45 deletions(-) diff --git a/miniprogram/pages/image-edit/image-edit.js b/miniprogram/pages/image-edit/image-edit.js index 36547f8..3f79bc3 100644 --- a/miniprogram/pages/image-edit/image-edit.js +++ b/miniprogram/pages/image-edit/image-edit.js @@ -445,23 +445,27 @@ Page({ // 将屏幕坐标系的偏移转换为原始图片坐标系的偏移 // 旋转是围绕图片中心点的,需要根据旋转角度转换坐标系 + // + // 关键理解: + // - 屏幕坐标系:X向右为正,Y向下为正 + // - 原始图片坐标系:X向右为正,Y向下为正 + // - 旋转90度后,屏幕上的X方向对应原始图片的-Y方向,屏幕上的Y方向对应原始图片的X方向 + // 所以:屏幕坐标(x,y) -> 原始图片坐标(y, -x) + // - 旋转270度后,屏幕上的X方向对应原始图片的Y方向,屏幕上的Y方向对应原始图片的-X方向 + // 所以:屏幕坐标(x,y) -> 原始图片坐标(-y, x) let offsetX, offsetY if (rotation === 90) { - // 顺时针旋转90度: - // 屏幕坐标系:X向右,Y向下 - // 原始图片坐标系:X向右,Y向下 - // 旋转90度后,屏幕上的X对应原始图片的-Y,屏幕上的Y对应原始图片的X - // 屏幕坐标(x,y) -> 原始图片坐标(-y, x) - offsetX = -screenOffsetY - offsetY = screenOffsetX + // 顺时针旋转90度:屏幕坐标(x,y) -> 原始图片坐标(y, -x) + offsetX = screenOffsetY // 屏幕Y -> 原始X + offsetY = -screenOffsetX // 屏幕X -> 原始Y(取反) } else if (rotation === 180) { // 旋转180度:屏幕坐标(x,y) -> 原始图片坐标(-x, -y) offsetX = -screenOffsetX offsetY = -screenOffsetY } else if (rotation === 270) { - // 顺时针旋转270度:屏幕坐标(x,y) -> 原始图片坐标(y, -x) - offsetX = screenOffsetY - offsetY = -screenOffsetX + // 顺时针旋转270度:屏幕坐标(x,y) -> 原始图片坐标(-y, x) + offsetX = -screenOffsetY // 屏幕Y -> 原始X(取反) + offsetY = screenOffsetX // 屏幕X -> 原始Y } else { // 不旋转:屏幕坐标 = 原始图片坐标 offsetX = screenOffsetX @@ -478,43 +482,41 @@ Page({ // 当旋转90/270度时,imageWidth在视觉上对应originalHeight,imageHeight对应originalWidth // 根据旋转角度确定正确的比例关系 + // 关键理解:offsetX/offsetY已经转换到原始图片坐标系 + // 所以应该使用统一的比例计算方式(不区分旋转) + // 因为CSS transform rotate不会改变DOM元素的尺寸 let displayToOriginalRatioX, displayToOriginalRatioY - if (rotation === 90 || rotation === 270) { - // 旋转90/270度: - // imageWidth是屏幕上显示的宽度,视觉上对应原始图片的高度 - // imageHeight是屏幕上显示的高度,视觉上对应原始图片的宽度 - // 但offsetX/offsetY已经转换到原始图片坐标系,所以: - // offsetX对应原始图片的X方向(宽度方向),需要用originalWidth的比例 - // offsetY对应原始图片的Y方向(高度方向),需要用originalHeight的比例 - // 但是,由于旋转,imageWidth对应originalHeight,imageHeight对应originalWidth - displayToOriginalRatioX = originalWidth / imageHeight // offsetX用imageHeight的比例 - displayToOriginalRatioY = originalHeight / imageWidth // offsetY用imageWidth的比例 - } else { - // 不旋转或旋转180度:显示宽对应原始宽,显示高对应原始高 - displayToOriginalRatioX = originalWidth / imageWidth - displayToOriginalRatioY = originalHeight / imageHeight - } + // 统一使用相同的比例计算方式 + displayToOriginalRatioX = originalWidth / imageWidth // 原始宽度 / 显示宽度 + displayToOriginalRatioY = originalHeight / imageHeight // 原始高度 / 显示高度 const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatioX const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatioY // 计算裁剪区域的尺寸(在原始图片上) - // 注意:旋转90/270度时,裁剪框的宽高在原始图片上也需要互换 + // 关键理解:无论是否旋转,裁剪框在屏幕上的大小都是396x396 + // 图片的显示尺寸(imageWidth, imageHeight)也不会因为CSS旋转而改变 + // 所以裁剪区域在原始图片上的尺寸应该使用相同的比例计算方式 let sourceWidth, sourceHeight - if (rotation === 90 || rotation === 270) { - // 旋转90/270度: - // cropDisplayWidth在屏幕上,视觉上对应原始图片的高度方向 - // cropDisplayHeight在屏幕上,视觉上对应原始图片的宽度方向 - // 所以:sourceWidth应该用cropDisplayHeight计算,sourceHeight应该用cropDisplayWidth计算 - // 但是要注意:displayToOriginalRatioX对应原始图片的X方向(宽度),displayToOriginalRatioY对应原始图片的Y方向(高度) - // 旋转90度后,屏幕上的宽度对应原始图片的高度,所以用displayToOriginalRatioY - // 屏幕上的高度对应原始图片的宽度,所以用displayToOriginalRatioX - sourceWidth = (cropDisplayHeight / scale) * displayToOriginalRatioX // 屏幕高度 -> 原始宽度 - sourceHeight = (cropDisplayWidth / scale) * displayToOriginalRatioY // 屏幕宽度 -> 原始高度 - } else { - sourceWidth = (cropDisplayWidth / scale) * displayToOriginalRatioX - sourceHeight = (cropDisplayHeight / scale) * displayToOriginalRatioY - } + let sizeInHeightDirection, sizeInWidthDirection // 用于日志输出 + + // 统一使用相同的比例计算方式(不区分旋转) + // 因为CSS transform rotate不会改变DOM元素的尺寸 + const ratioX = originalWidth / imageWidth // 原始宽度 / 显示宽度 + const ratioY = originalHeight / imageHeight // 原始高度 / 显示高度 + + // 裁剪框是正方形,在原始图片上也应该是正方形 + // 使用两个方向的平均比例,或者取较小值确保不超出 + const sourceSizeX = (cropDisplayWidth / scale) * ratioX + const sourceSizeY = (cropDisplayHeight / scale) * ratioY + + // 取较小值,确保裁剪区域是正方形且不超出图片范围 + const sourceSize = Math.min(sourceSizeX, sourceSizeY) + sourceWidth = sourceSize + sourceHeight = sourceSize + + sizeInHeightDirection = sourceSizeY + sizeInWidthDirection = sourceSizeX // 计算裁剪区域的起始位置(在原始图片上) // 原始图片中心点 + 偏移 - 裁剪区域尺寸的一半 @@ -527,7 +529,7 @@ Page({ sourceWidth = Math.min(sourceWidth, originalWidth - sourceX) sourceHeight = Math.min(sourceHeight, originalHeight - sourceY) - console.log('裁剪参数:', JSON.stringify({ + console.log('裁剪参数:', JSON.stringify({ 屏幕尺寸: { screenWidth, screenHeight }, 裁剪框位置: { cropLeft, cropTop, cropDisplayWidth, cropDisplayHeight }, 图片中心: { imageCenterX, imageCenterY }, @@ -542,11 +544,19 @@ Page({ 原始偏移: { offsetInOriginalX, offsetInOriginalY }, 显示到原始比例: { X: displayToOriginalRatioX, Y: displayToOriginalRatioY }, 裁剪区域尺寸计算: { - cropDisplayWidth: cropDisplayWidth, - cropDisplayHeight: cropDisplayHeight, - scale: scale, + cropDisplayWidth: cropDisplayWidth, + cropDisplayHeight: cropDisplayHeight, + scale: scale, ratioX: displayToOriginalRatioX, - ratioY: displayToOriginalRatioY + ratioY: displayToOriginalRatioY, + 计算过程: rotation === 90 || rotation === 270 ? { + 高度方向计算: `(cropDisplayWidth=${cropDisplayWidth} / scale=${scale}) * ratioY=${displayToOriginalRatioY} = ${sizeInHeightDirection}`, + 宽度方向计算: `(cropDisplayHeight=${cropDisplayHeight} / scale=${scale}) * ratioX=${displayToOriginalRatioX} = ${sizeInWidthDirection}`, + 最终尺寸: `Math.min(${sizeInHeightDirection}, ${sizeInWidthDirection}) = ${sourceWidth}` + } : { + sourceWidth计算: `(cropDisplayWidth=${cropDisplayWidth} / scale=${scale}) * ratioX=${displayToOriginalRatioX} = ${sourceWidth}`, + sourceHeight计算: `(cropDisplayHeight=${cropDisplayHeight} / scale=${scale}) * ratioY=${displayToOriginalRatioY} = ${sourceHeight}` + } }, 转换后裁剪区域: { sourceX, sourceY, sourceWidth, sourceHeight }, 目标尺寸: { cropWidth, cropHeight }, -- Gitee From f01ab6a80c9274ec8045c94fb46bbad67c45ffe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Wed, 12 Nov 2025 17:26:47 +0800 Subject: [PATCH 31/44] =?UTF-8?q?=E6=AD=A5=E9=AA=A4=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E6=97=8B=E8=BD=AC=E5=90=8E=E6=98=BE=E7=A4=BA=E6=AD=A3=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/pages/image-edit/image-edit.js | 438 ++++++++++++++++----- 1 file changed, 336 insertions(+), 102 deletions(-) diff --git a/miniprogram/pages/image-edit/image-edit.js b/miniprogram/pages/image-edit/image-edit.js index 3f79bc3..fa5f10e 100644 --- a/miniprogram/pages/image-edit/image-edit.js +++ b/miniprogram/pages/image-edit/image-edit.js @@ -94,6 +94,17 @@ Page({ * 计算图片布局 */ calculateImageLayout(imageInfo) { + if (this.data.type === 'step') { + this.calculateStepImageLayout(imageInfo) + } else { + this.calculateDishImageLayout(imageInfo) + } + }, + + /** + * 计算菜品图片布局(正方形750x750) + */ + calculateDishImageLayout(imageInfo) { const { width, height } = imageInfo const { cropWidth, cropHeight } = this.data @@ -157,8 +168,7 @@ Page({ const scaleX = cropDisplayWidth / imageDisplayWidth const scaleY = cropDisplayHeight / imageDisplayHeight const minScale = Math.max(scaleX, scaleY, 1.0) // 至少为1.0,确保能覆盖裁剪框 - // 最小缩放比例不能小于0.5 - const finalMinScale = Math.max(minScale, 0.5) + const finalMinScale = minScale this.setData({ originalWidth: width, @@ -178,7 +188,7 @@ Page({ translateY: 0 }) - console.log('图片布局计算完成:', JSON.stringify({ + console.log('菜品图片布局计算完成:', JSON.stringify({ 原始尺寸: { width, height }, 显示尺寸: { imageDisplayWidth, imageDisplayHeight }, 裁剪框显示尺寸: { cropDisplayWidth, cropDisplayHeight }, @@ -186,6 +196,149 @@ Page({ }, null, 2)) }, + /** + * 计算步骤图片布局(矩形1280x960,4:3比例) + * 考虑旋转后的视觉尺寸和贴边显示 + */ + calculateStepImageLayout(imageInfo) { + const { width, height } = imageInfo + const { cropWidth, cropHeight, rotation } = this.data + + // 获取屏幕信息 + const systemInfo = wx.getSystemInfoSync() + const screenWidth = systemInfo.windowWidth + const screenHeight = systemInfo.windowHeight + + // 考虑旋转后的视觉尺寸 + // 旋转90/270度时,视觉上的宽高会互换 + let visualImageWidth, visualImageHeight + if (rotation === 90 || rotation === 270) { + // 旋转90/270度:视觉宽度 = 实际高度,视觉高度 = 实际宽度 + visualImageWidth = height + visualImageHeight = width + } else { + // 不旋转或旋转180度:视觉尺寸 = 实际尺寸 + visualImageWidth = width + visualImageHeight = height + } + + // 视觉上的图片宽高比 + const visualImageRatio = visualImageWidth / visualImageHeight + + // 计算裁剪框在屏幕上的显示尺寸(保持比例) + const cropRatio = cropWidth / cropHeight // 4:3 + let cropDisplayWidth, cropDisplayHeight + if (screenWidth / screenHeight > cropRatio) { + // 屏幕更宽,以高度为基准 + cropDisplayHeight = screenHeight * 0.7 // 占用70%的屏幕高度 + cropDisplayWidth = cropDisplayHeight * cropRatio + } else { + // 屏幕更高,以宽度为基准 + cropDisplayWidth = screenWidth * 0.9 // 占用90%的屏幕宽度 + cropDisplayHeight = cropDisplayWidth / cropRatio + } + + // 确保裁剪框不会太大 + if (cropDisplayWidth > screenWidth * 0.9) { + cropDisplayWidth = screenWidth * 0.9 + cropDisplayHeight = cropDisplayWidth / cropRatio + } + if (cropDisplayHeight > screenHeight * 0.7) { + cropDisplayHeight = screenHeight * 0.7 + cropDisplayWidth = cropDisplayHeight * cropRatio + } + + // 计算图片的显示尺寸(基于视觉尺寸) + // 需求:短边与裁剪框一致(不限高度还是宽度) + // 比较图片和裁剪框的宽高比,决定以哪一边为基准 + let imageDisplayWidth, imageDisplayHeight + if (visualImageRatio > cropRatio) { + // 图片更宽(横向),高度与裁剪框高度一致 + imageDisplayHeight = cropDisplayHeight + imageDisplayWidth = imageDisplayHeight * visualImageRatio + } else { + // 图片更高(纵向)或等比例,宽度与裁剪框宽度一致 + imageDisplayWidth = cropDisplayWidth + imageDisplayHeight = imageDisplayWidth / visualImageRatio + } + + // 关键修复:当旋转90/270度时,CSS的rotate变换会改变视觉上的宽高 + // 但实际设置的width/height属性不会改变,所以需要交换宽高值 + // 这样CSS旋转后,视觉上的尺寸才是正确的 + let cssImageWidth, cssImageHeight + if (rotation === 90 || rotation === 270) { + // 旋转90/270度:交换宽高,这样CSS旋转后视觉尺寸才是正确的 + cssImageWidth = imageDisplayHeight + cssImageHeight = imageDisplayWidth + } else { + // 不旋转或旋转180度:保持原样 + cssImageWidth = imageDisplayWidth + cssImageHeight = imageDisplayHeight + } + + // 计算裁剪框在屏幕上的位置(居中) + const cropTop = (screenHeight - cropDisplayHeight) / 2 + const cropLeft = (screenWidth - cropDisplayWidth) / 2 + + // 计算图片位置(居中显示) + // 注意:当旋转90/270度时,需要使用CSS宽高来计算位置 + const imageLeft = (screenWidth - cssImageWidth) / 2 + const imageTop = (screenHeight - cssImageHeight) / 2 + + // 计算最小缩放比例(贴边显示) + // 确保图片缩放后能够完全覆盖裁剪框 + // 需要同时考虑宽度和高度方向 + // 注意:这里使用视觉尺寸(imageDisplayWidth/Height)来计算缩放 + const scaleX = cropDisplayWidth / imageDisplayWidth + const scaleY = cropDisplayHeight / imageDisplayHeight + // 不强制至少为1.0,如果图片显示尺寸大于裁剪框,允许缩小 + // 取较大值确保图片能完全覆盖裁剪框(宽度和高度方向都要覆盖) + // 如果图片显示尺寸小于裁剪框,需要放大(scale > 1) + // 如果图片显示尺寸大于裁剪框,需要缩小(scale < 1) + // 添加一个小的容差值(0.001),确保图片能完全覆盖裁剪框,避免浮点数精度问题 + const minScale = Math.max(scaleX, scaleY) * 1.001 + + // 初始缩放设置为minScale,确保图片一开始就能完全覆盖裁剪框 + // 如果minScale < 1,说明图片需要缩小才能覆盖;如果minScale > 1,说明图片需要放大才能覆盖 + const initialScale = minScale + + this.setData({ + originalWidth: width, + originalHeight: height, + imageWidth: cssImageWidth, // 使用CSS宽高(旋转时已交换) + imageHeight: cssImageHeight, // 使用CSS宽高(旋转时已交换) + imageTop: imageTop, + imageLeft: imageLeft, + cropDisplayWidth: cropDisplayWidth, + cropDisplayHeight: cropDisplayHeight, + cropTop: cropTop, + cropLeft: cropLeft, + scale: initialScale, // 初始缩放设置为minScale,确保贴边显示 + minScale: minScale, + maxScale: 3, + translateX: 0, + translateY: 0 + }) + + console.log('步骤图片布局计算完成:', JSON.stringify({ + 原始尺寸: { width, height }, + 视觉尺寸: { visualImageWidth, visualImageHeight }, + 显示尺寸: { imageDisplayWidth, imageDisplayHeight }, + CSS尺寸: { cssImageWidth, cssImageHeight }, + 裁剪框显示尺寸: { cropDisplayWidth, cropDisplayHeight }, + 屏幕尺寸: { screenWidth, screenHeight }, + 旋转角度: rotation, + 缩放计算: { + scaleX: scaleX, + scaleY: scaleY, + minScale: minScale, + initialScale: initialScale + }, + 最小缩放: minScale, + 初始缩放: initialScale + }, null, 2)) + }, + /** * 触摸开始 */ @@ -409,23 +562,27 @@ Page({ * 确认编辑 */ confirmEdit() { - console.log('开始处理图片') + if (this.data.type === 'step') { + this.confirmEditStep() + } else { + this.confirmEditDish() + } + }, + + /** + * 确认编辑 - 菜品图片(正方形750x750) + */ + confirmEditDish() { + console.log('开始处理菜品图片') showLoading('处理中...') const { src, originalWidth, originalHeight, imageWidth, imageHeight, imageTop, imageLeft, cropWidth, cropHeight, cropDisplayWidth, cropDisplayHeight, cropTop, cropLeft, - scale, translateX, translateY, rotation, type } = this.data + scale, translateX, translateY, rotation } = this.data // 创建画布上下文用于最终输出 const finalCtx = wx.createCanvasContext('finalCanvas', this) - // 计算裁剪区域在原始图片上的位置 - // 参考头像裁切的算法,但需要处理旋转 - - // 关键点:CSS transform rotate 不会改变DOM元素的宽高 - // 但布局计算时可能已经考虑了旋转后的视觉尺寸 - // 需要基于原始图片的实际尺寸来计算 - // 获取屏幕尺寸(用于计算旋转后的坐标转换) const screenWidth = wx.getSystemInfoSync().windowWidth const screenHeight = wx.getSystemInfoSync().windowHeight @@ -439,87 +596,183 @@ Page({ const cropCenterY = cropTop + cropDisplayHeight / 2 // 将裁剪框中心点相对于图片中心点的偏移(在屏幕坐标系下) - // 注意:translateX/translateY是屏幕坐标系下的位移 let screenOffsetX = cropCenterX - imageCenterX - translateX let screenOffsetY = cropCenterY - imageCenterY - translateY // 将屏幕坐标系的偏移转换为原始图片坐标系的偏移 - // 旋转是围绕图片中心点的,需要根据旋转角度转换坐标系 - // - // 关键理解: - // - 屏幕坐标系:X向右为正,Y向下为正 - // - 原始图片坐标系:X向右为正,Y向下为正 - // - 旋转90度后,屏幕上的X方向对应原始图片的-Y方向,屏幕上的Y方向对应原始图片的X方向 - // 所以:屏幕坐标(x,y) -> 原始图片坐标(y, -x) - // - 旋转270度后,屏幕上的X方向对应原始图片的Y方向,屏幕上的Y方向对应原始图片的-X方向 - // 所以:屏幕坐标(x,y) -> 原始图片坐标(-y, x) let offsetX, offsetY if (rotation === 90) { - // 顺时针旋转90度:屏幕坐标(x,y) -> 原始图片坐标(y, -x) - offsetX = screenOffsetY // 屏幕Y -> 原始X - offsetY = -screenOffsetX // 屏幕X -> 原始Y(取反) + offsetX = screenOffsetY + offsetY = -screenOffsetX } else if (rotation === 180) { - // 旋转180度:屏幕坐标(x,y) -> 原始图片坐标(-x, -y) offsetX = -screenOffsetX offsetY = -screenOffsetY } else if (rotation === 270) { - // 顺时针旋转270度:屏幕坐标(x,y) -> 原始图片坐标(-y, x) - offsetX = -screenOffsetY // 屏幕Y -> 原始X(取反) - offsetY = screenOffsetX // 屏幕X -> 原始Y + offsetX = -screenOffsetY + offsetY = screenOffsetX } else { - // 不旋转:屏幕坐标 = 原始图片坐标 offsetX = screenOffsetX offsetY = screenOffsetY } - // 将偏移转换为原始图片坐标(考虑缩放和旋转) - // offsetX/offsetY 是相对于图片中心的偏移(在显示尺寸上的,已转换到原始图片坐标系) - // 需要除以scale得到在基准显示尺寸上的偏移 - // 再乘以显示到原始的比例得到在原始图片上的偏移 + // 计算比例 + const displayToOriginalRatioX = originalWidth / imageWidth + const displayToOriginalRatioY = originalHeight / imageHeight + + const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatioX + const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatioY + + // 计算裁剪区域的尺寸(菜品图片是正方形) + const ratioX = originalWidth / imageWidth + const ratioY = originalHeight / imageHeight + const sourceSizeX = (cropDisplayWidth / scale) * ratioX + const sourceSizeY = (cropDisplayHeight / scale) * ratioY + + // 取较小值,确保裁剪区域是正方形且不超出图片范围 + const sourceSize = Math.min(sourceSizeX, sourceSizeY) + const sourceWidth = sourceSize + const sourceHeight = sourceSize + + // 计算裁剪区域的起始位置 + let sourceX = originalWidth / 2 + offsetInOriginalX - sourceWidth / 2 + let sourceY = originalHeight / 2 + offsetInOriginalY - sourceHeight / 2 + + // 确保裁剪区域在图片范围内 + sourceX = Math.max(0, Math.min(sourceX, originalWidth - sourceWidth)) + sourceY = Math.max(0, Math.min(sourceY, originalHeight - sourceHeight)) + + // 绘制裁剪后的图片 + if (rotation === 0) { + finalCtx.drawImage( + src, + sourceX, sourceY, sourceWidth, sourceHeight, + 0, 0, cropWidth, cropHeight + ) + } else { + finalCtx.save() + finalCtx.translate(cropWidth / 2, cropHeight / 2) + finalCtx.rotate(rotation * Math.PI / 180) + finalCtx.drawImage( + src, + sourceX, sourceY, sourceWidth, sourceHeight, + -cropWidth / 2, -cropHeight / 2, cropWidth, cropHeight + ) + finalCtx.restore() + } + + finalCtx.draw(false, () => { + this.exportCanvas() + }) + }, + + /** + * 确认编辑 - 步骤图片(矩形1280x960,4:3比例) + */ + confirmEditStep() { + console.log('开始处理步骤图片') + showLoading('处理中...') + + const { src, originalWidth, originalHeight, imageWidth, imageHeight, imageTop, imageLeft, + cropWidth, cropHeight, cropDisplayWidth, cropDisplayHeight, cropTop, cropLeft, + scale, translateX, translateY, rotation } = this.data - // 关键:imageWidth和imageHeight是DOM元素的实际宽高(CSS transform不会改变) - // 在calculateImageLayout中,图片显示尺寸的短边与裁剪框一致(宽度或高度) - // 当旋转90/270度时,imageWidth在视觉上对应originalHeight,imageHeight对应originalWidth + // 创建画布上下文用于最终输出 + const finalCtx = wx.createCanvasContext('finalCanvas', this) - // 根据旋转角度确定正确的比例关系 - // 关键理解:offsetX/offsetY已经转换到原始图片坐标系 - // 所以应该使用统一的比例计算方式(不区分旋转) - // 因为CSS transform rotate不会改变DOM元素的尺寸 + // 获取屏幕尺寸 + const screenWidth = wx.getSystemInfoSync().windowWidth + const screenHeight = wx.getSystemInfoSync().windowHeight + + // 计算图片中心点(在屏幕上的位置) + const imageCenterX = imageLeft + imageWidth / 2 + const imageCenterY = imageTop + imageHeight / 2 + + // 计算裁剪框中心点(在屏幕上的位置) + const cropCenterX = cropLeft + cropDisplayWidth / 2 + const cropCenterY = cropTop + cropDisplayHeight / 2 + + // 将裁剪框中心点相对于图片中心点的偏移(在屏幕坐标系下) + let screenOffsetX = cropCenterX - imageCenterX - translateX + let screenOffsetY = cropCenterY - imageCenterY - translateY + + // 将屏幕坐标系的偏移转换为原始图片坐标系的偏移 + let offsetX, offsetY + if (rotation === 90) { + offsetX = screenOffsetY + offsetY = -screenOffsetX + } else if (rotation === 180) { + offsetX = -screenOffsetX + offsetY = -screenOffsetY + } else if (rotation === 270) { + offsetX = -screenOffsetY + offsetY = screenOffsetX + } else { + offsetX = screenOffsetX + offsetY = screenOffsetY + } + + // 计算比例 + // 关键:当旋转90/270度时,imageWidth在视觉上对应originalHeight,imageHeight对应originalWidth let displayToOriginalRatioX, displayToOriginalRatioY - // 统一使用相同的比例计算方式 - displayToOriginalRatioX = originalWidth / imageWidth // 原始宽度 / 显示宽度 - displayToOriginalRatioY = originalHeight / imageHeight // 原始高度 / 显示高度 + if (rotation === 90 || rotation === 270) { + // 旋转90/270度:视觉宽度对应原始高度,视觉高度对应原始宽度 + displayToOriginalRatioX = originalHeight / imageWidth // 视觉宽度 -> 原始高度 + displayToOriginalRatioY = originalWidth / imageHeight // 视觉高度 -> 原始宽度 + } else { + // 不旋转或旋转180度:视觉尺寸对应原始尺寸 + displayToOriginalRatioX = originalWidth / imageWidth + displayToOriginalRatioY = originalHeight / imageHeight + } const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatioX const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatioY - // 计算裁剪区域的尺寸(在原始图片上) - // 关键理解:无论是否旋转,裁剪框在屏幕上的大小都是396x396 - // 图片的显示尺寸(imageWidth, imageHeight)也不会因为CSS旋转而改变 - // 所以裁剪区域在原始图片上的尺寸应该使用相同的比例计算方式 - let sourceWidth, sourceHeight - let sizeInHeightDirection, sizeInWidthDirection // 用于日志输出 - - // 统一使用相同的比例计算方式(不区分旋转) - // 因为CSS transform rotate不会改变DOM元素的尺寸 - const ratioX = originalWidth / imageWidth // 原始宽度 / 显示宽度 - const ratioY = originalHeight / imageHeight // 原始高度 / 显示高度 + // 计算裁剪区域的尺寸(步骤图片是矩形,保持4:3比例) + // 注意:cropDisplayWidth和cropDisplayHeight是屏幕上的显示尺寸,不受旋转影响 + // 但需要根据旋转角度正确计算在原始图片上的尺寸 + let ratioX, ratioY + if (rotation === 90 || rotation === 270) { + // 旋转90/270度:裁剪框的宽度方向对应原始图片的高度方向,高度方向对应原始图片的宽度方向 + ratioX = originalHeight / imageWidth // 裁剪框宽度方向 -> 原始图片高度方向 + ratioY = originalWidth / imageHeight // 裁剪框高度方向 -> 原始图片宽度方向 + } else { + // 不旋转或旋转180度:正常对应 + ratioX = originalWidth / imageWidth + ratioY = originalHeight / imageHeight + } - // 裁剪框是正方形,在原始图片上也应该是正方形 - // 使用两个方向的平均比例,或者取较小值确保不超出 const sourceSizeX = (cropDisplayWidth / scale) * ratioX const sourceSizeY = (cropDisplayHeight / scale) * ratioY - // 取较小值,确保裁剪区域是正方形且不超出图片范围 - const sourceSize = Math.min(sourceSizeX, sourceSizeY) - sourceWidth = sourceSize - sourceHeight = sourceSize + // 步骤图片需要保持矩形比例,不能强制为正方形 + // 根据旋转角度决定是否需要交换宽高 + let sourceWidth, sourceHeight + if (rotation === 90 || rotation === 270) { + // 旋转90/270度后,视觉上的宽高互换 + // 裁剪区域在原始图片上:宽度对应原始高度,高度对应原始宽度 + sourceWidth = sourceSizeX // 保持计算出的尺寸 + sourceHeight = sourceSizeY + } else { + // 不旋转或旋转180度,保持原始比例 + sourceWidth = sourceSizeX + sourceHeight = sourceSizeY + } - sizeInHeightDirection = sourceSizeY - sizeInWidthDirection = sourceSizeX + // 确保保持4:3的比例(允许小的误差) + const targetRatio = cropWidth / cropHeight // 1280/960 = 4/3 + const currentRatio = sourceWidth / sourceHeight + if (Math.abs(currentRatio - targetRatio) > 0.01) { + // 如果比例不匹配,以较小的尺寸为基准调整 + if (currentRatio > targetRatio) { + // 当前更宽,以高度为基准 + sourceWidth = sourceHeight * targetRatio + } else { + // 当前更高,以宽度为基准 + sourceHeight = sourceWidth / targetRatio + } + } - // 计算裁剪区域的起始位置(在原始图片上) - // 原始图片中心点 + 偏移 - 裁剪区域尺寸的一半 + // 计算裁剪区域的起始位置 let sourceX = originalWidth / 2 + offsetInOriginalX - sourceWidth / 2 let sourceY = originalHeight / 2 + offsetInOriginalY - sourceHeight / 2 @@ -529,61 +782,42 @@ Page({ sourceWidth = Math.min(sourceWidth, originalWidth - sourceX) sourceHeight = Math.min(sourceHeight, originalHeight - sourceY) - console.log('裁剪参数:', JSON.stringify({ - 屏幕尺寸: { screenWidth, screenHeight }, - 裁剪框位置: { cropLeft, cropTop, cropDisplayWidth, cropDisplayHeight }, - 图片中心: { imageCenterX, imageCenterY }, - 裁剪框中心: { cropCenterX, cropCenterY }, + console.log('步骤图片裁剪参数:', JSON.stringify({ 原始图片尺寸: { originalWidth, originalHeight }, - 基准显示尺寸: { imageWidth, imageHeight }, - 基准显示位置: { imageTop, imageLeft }, + 显示尺寸: { imageWidth, imageHeight }, + 裁剪框显示尺寸: { cropDisplayWidth, cropDisplayHeight }, + 裁剪框目标尺寸: { cropWidth, cropHeight }, 用户缩放: scale, 用户位移: { translateX, translateY }, - 屏幕偏移: { screenOffsetX, screenOffsetY }, - 转换后偏移: { offsetX, offsetY }, - 原始偏移: { offsetInOriginalX, offsetInOriginalY }, - 显示到原始比例: { X: displayToOriginalRatioX, Y: displayToOriginalRatioY }, + 旋转: rotation, + 比例计算: { + displayToOriginalRatioX, + displayToOriginalRatioY, + ratioX, + ratioY + }, 裁剪区域尺寸计算: { - cropDisplayWidth: cropDisplayWidth, - cropDisplayHeight: cropDisplayHeight, - scale: scale, - ratioX: displayToOriginalRatioX, - ratioY: displayToOriginalRatioY, - 计算过程: rotation === 90 || rotation === 270 ? { - 高度方向计算: `(cropDisplayWidth=${cropDisplayWidth} / scale=${scale}) * ratioY=${displayToOriginalRatioY} = ${sizeInHeightDirection}`, - 宽度方向计算: `(cropDisplayHeight=${cropDisplayHeight} / scale=${scale}) * ratioX=${displayToOriginalRatioX} = ${sizeInWidthDirection}`, - 最终尺寸: `Math.min(${sizeInHeightDirection}, ${sizeInWidthDirection}) = ${sourceWidth}` - } : { - sourceWidth计算: `(cropDisplayWidth=${cropDisplayWidth} / scale=${scale}) * ratioX=${displayToOriginalRatioX} = ${sourceWidth}`, - sourceHeight计算: `(cropDisplayHeight=${cropDisplayHeight} / scale=${scale}) * ratioY=${displayToOriginalRatioY} = ${sourceHeight}` - } + sourceSizeX, + sourceSizeY, + sourceWidth, + sourceHeight, + 比例: sourceWidth / sourceHeight, + 目标比例: cropWidth / cropHeight }, - 转换后裁剪区域: { sourceX, sourceY, sourceWidth, sourceHeight }, - 目标尺寸: { cropWidth, cropHeight }, - 旋转: rotation + 裁剪区域: { sourceX, sourceY, sourceWidth, sourceHeight } }, null, 2)) // 绘制裁剪后的图片 - // 如果旋转了,需要在canvas上旋转绘制,但裁剪区域已经是原始图片坐标 if (rotation === 0) { - // 不旋转,直接裁剪 finalCtx.drawImage( src, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, cropWidth, cropHeight ) } else { - // 需要旋转:先裁剪原图区域,然后在最终画布上旋转 - // 注意:这里sourceX, sourceY已经是原始图片的坐标了 - // 当旋转90/270度时,sourceWidth和sourceHeight已经互换了 - // 在画布中心旋转并绘制裁剪区域 finalCtx.save() finalCtx.translate(cropWidth / 2, cropHeight / 2) finalCtx.rotate(rotation * Math.PI / 180) - - // 绘制裁剪区域到canvas - // 注意:当旋转90/270度时,sourceWidth和sourceHeight已经互换了 - // 所以直接绘制sourceWidth x sourceHeight的区域,旋转后会自动变成正确的方向 finalCtx.drawImage( src, sourceX, sourceY, sourceWidth, sourceHeight, -- Gitee From 643157a37cf25d5eac7f85aca27e7c918e8e01a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Wed, 12 Nov 2025 19:32:45 +0800 Subject: [PATCH 32/44] =?UTF-8?q?=E8=BF=98=E5=8E=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/pages/image-edit/image-edit.js | 173 ++++----------------- 1 file changed, 27 insertions(+), 146 deletions(-) diff --git a/miniprogram/pages/image-edit/image-edit.js b/miniprogram/pages/image-edit/image-edit.js index fa5f10e..f40275f 100644 --- a/miniprogram/pages/image-edit/image-edit.js +++ b/miniprogram/pages/image-edit/image-edit.js @@ -198,35 +198,22 @@ Page({ /** * 计算步骤图片布局(矩形1280x960,4:3比例) - * 考虑旋转后的视觉尺寸和贴边显示 */ calculateStepImageLayout(imageInfo) { const { width, height } = imageInfo - const { cropWidth, cropHeight, rotation } = this.data + const { cropWidth, cropHeight } = this.data // 获取屏幕信息 const systemInfo = wx.getSystemInfoSync() const screenWidth = systemInfo.windowWidth const screenHeight = systemInfo.windowHeight - // 考虑旋转后的视觉尺寸 - // 旋转90/270度时,视觉上的宽高会互换 - let visualImageWidth, visualImageHeight - if (rotation === 90 || rotation === 270) { - // 旋转90/270度:视觉宽度 = 实际高度,视觉高度 = 实际宽度 - visualImageWidth = height - visualImageHeight = width - } else { - // 不旋转或旋转180度:视觉尺寸 = 实际尺寸 - visualImageWidth = width - visualImageHeight = height - } - - // 视觉上的图片宽高比 - const visualImageRatio = visualImageWidth / visualImageHeight + // 图片的宽高比是固定的,旋转只是CSS变换,不影响布局计算 + // 始终使用原始图片的宽高比 + const imageRatio = width / height // 计算裁剪框在屏幕上的显示尺寸(保持比例) - const cropRatio = cropWidth / cropHeight // 4:3 + const cropRatio = cropWidth / cropHeight let cropDisplayWidth, cropDisplayHeight if (screenWidth / screenHeight > cropRatio) { // 屏幕更宽,以高度为基准 @@ -248,32 +235,18 @@ Page({ cropDisplayWidth = cropDisplayHeight * cropRatio } - // 计算图片的显示尺寸(基于视觉尺寸) + // 计算图片的显示尺寸 // 需求:短边与裁剪框一致(不限高度还是宽度) // 比较图片和裁剪框的宽高比,决定以哪一边为基准 let imageDisplayWidth, imageDisplayHeight - if (visualImageRatio > cropRatio) { + if (imageRatio > cropRatio) { // 图片更宽(横向),高度与裁剪框高度一致 imageDisplayHeight = cropDisplayHeight - imageDisplayWidth = imageDisplayHeight * visualImageRatio + imageDisplayWidth = imageDisplayHeight * imageRatio } else { // 图片更高(纵向)或等比例,宽度与裁剪框宽度一致 imageDisplayWidth = cropDisplayWidth - imageDisplayHeight = imageDisplayWidth / visualImageRatio - } - - // 关键修复:当旋转90/270度时,CSS的rotate变换会改变视觉上的宽高 - // 但实际设置的width/height属性不会改变,所以需要交换宽高值 - // 这样CSS旋转后,视觉上的尺寸才是正确的 - let cssImageWidth, cssImageHeight - if (rotation === 90 || rotation === 270) { - // 旋转90/270度:交换宽高,这样CSS旋转后视觉尺寸才是正确的 - cssImageWidth = imageDisplayHeight - cssImageHeight = imageDisplayWidth - } else { - // 不旋转或旋转180度:保持原样 - cssImageWidth = imageDisplayWidth - cssImageHeight = imageDisplayHeight + imageDisplayHeight = imageDisplayWidth / imageRatio } // 计算裁剪框在屏幕上的位置(居中) @@ -281,40 +254,30 @@ Page({ const cropLeft = (screenWidth - cropDisplayWidth) / 2 // 计算图片位置(居中显示) - // 注意:当旋转90/270度时,需要使用CSS宽高来计算位置 - const imageLeft = (screenWidth - cssImageWidth) / 2 - const imageTop = (screenHeight - cssImageHeight) / 2 + const imageLeft = (screenWidth - imageDisplayWidth) / 2 + const imageTop = (screenHeight - imageDisplayHeight) / 2 - // 计算最小缩放比例(贴边显示) + // 计算最小缩放比例 // 确保图片缩放后能够完全覆盖裁剪框 // 需要同时考虑宽度和高度方向 - // 注意:这里使用视觉尺寸(imageDisplayWidth/Height)来计算缩放 const scaleX = cropDisplayWidth / imageDisplayWidth const scaleY = cropDisplayHeight / imageDisplayHeight - // 不强制至少为1.0,如果图片显示尺寸大于裁剪框,允许缩小 - // 取较大值确保图片能完全覆盖裁剪框(宽度和高度方向都要覆盖) - // 如果图片显示尺寸小于裁剪框,需要放大(scale > 1) - // 如果图片显示尺寸大于裁剪框,需要缩小(scale < 1) - // 添加一个小的容差值(0.001),确保图片能完全覆盖裁剪框,避免浮点数精度问题 - const minScale = Math.max(scaleX, scaleY) * 1.001 - - // 初始缩放设置为minScale,确保图片一开始就能完全覆盖裁剪框 - // 如果minScale < 1,说明图片需要缩小才能覆盖;如果minScale > 1,说明图片需要放大才能覆盖 - const initialScale = minScale + const minScale = Math.max(scaleX, scaleY, 1.0) // 至少为1.0,确保能覆盖裁剪框 + const finalMinScale = minScale this.setData({ originalWidth: width, originalHeight: height, - imageWidth: cssImageWidth, // 使用CSS宽高(旋转时已交换) - imageHeight: cssImageHeight, // 使用CSS宽高(旋转时已交换) + imageWidth: imageDisplayWidth, + imageHeight: imageDisplayHeight, imageTop: imageTop, imageLeft: imageLeft, cropDisplayWidth: cropDisplayWidth, cropDisplayHeight: cropDisplayHeight, cropTop: cropTop, cropLeft: cropLeft, - scale: initialScale, // 初始缩放设置为minScale,确保贴边显示 - minScale: minScale, + scale: 1, + minScale: finalMinScale, maxScale: 3, translateX: 0, translateY: 0 @@ -322,20 +285,9 @@ Page({ console.log('步骤图片布局计算完成:', JSON.stringify({ 原始尺寸: { width, height }, - 视觉尺寸: { visualImageWidth, visualImageHeight }, 显示尺寸: { imageDisplayWidth, imageDisplayHeight }, - CSS尺寸: { cssImageWidth, cssImageHeight }, 裁剪框显示尺寸: { cropDisplayWidth, cropDisplayHeight }, - 屏幕尺寸: { screenWidth, screenHeight }, - 旋转角度: rotation, - 缩放计算: { - scaleX: scaleX, - scaleY: scaleY, - minScale: minScale, - initialScale: initialScale - }, - 最小缩放: minScale, - 初始缩放: initialScale + 屏幕尺寸: { screenWidth, screenHeight } }, null, 2)) }, @@ -679,7 +631,7 @@ Page({ // 创建画布上下文用于最终输出 const finalCtx = wx.createCanvasContext('finalCanvas', this) - // 获取屏幕尺寸 + // 获取屏幕尺寸(用于计算旋转后的坐标转换) const screenWidth = wx.getSystemInfoSync().windowWidth const screenHeight = wx.getSystemInfoSync().windowHeight @@ -712,65 +664,21 @@ Page({ } // 计算比例 - // 关键:当旋转90/270度时,imageWidth在视觉上对应originalHeight,imageHeight对应originalWidth - let displayToOriginalRatioX, displayToOriginalRatioY - if (rotation === 90 || rotation === 270) { - // 旋转90/270度:视觉宽度对应原始高度,视觉高度对应原始宽度 - displayToOriginalRatioX = originalHeight / imageWidth // 视觉宽度 -> 原始高度 - displayToOriginalRatioY = originalWidth / imageHeight // 视觉高度 -> 原始宽度 - } else { - // 不旋转或旋转180度:视觉尺寸对应原始尺寸 - displayToOriginalRatioX = originalWidth / imageWidth - displayToOriginalRatioY = originalHeight / imageHeight - } + const displayToOriginalRatioX = originalWidth / imageWidth + const displayToOriginalRatioY = originalHeight / imageHeight const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatioX const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatioY // 计算裁剪区域的尺寸(步骤图片是矩形,保持4:3比例) - // 注意:cropDisplayWidth和cropDisplayHeight是屏幕上的显示尺寸,不受旋转影响 - // 但需要根据旋转角度正确计算在原始图片上的尺寸 - let ratioX, ratioY - if (rotation === 90 || rotation === 270) { - // 旋转90/270度:裁剪框的宽度方向对应原始图片的高度方向,高度方向对应原始图片的宽度方向 - ratioX = originalHeight / imageWidth // 裁剪框宽度方向 -> 原始图片高度方向 - ratioY = originalWidth / imageHeight // 裁剪框高度方向 -> 原始图片宽度方向 - } else { - // 不旋转或旋转180度:正常对应 - ratioX = originalWidth / imageWidth - ratioY = originalHeight / imageHeight - } - + const ratioX = originalWidth / imageWidth + const ratioY = originalHeight / imageHeight const sourceSizeX = (cropDisplayWidth / scale) * ratioX const sourceSizeY = (cropDisplayHeight / scale) * ratioY - // 步骤图片需要保持矩形比例,不能强制为正方形 - // 根据旋转角度决定是否需要交换宽高 - let sourceWidth, sourceHeight - if (rotation === 90 || rotation === 270) { - // 旋转90/270度后,视觉上的宽高互换 - // 裁剪区域在原始图片上:宽度对应原始高度,高度对应原始宽度 - sourceWidth = sourceSizeX // 保持计算出的尺寸 - sourceHeight = sourceSizeY - } else { - // 不旋转或旋转180度,保持原始比例 - sourceWidth = sourceSizeX - sourceHeight = sourceSizeY - } - - // 确保保持4:3的比例(允许小的误差) - const targetRatio = cropWidth / cropHeight // 1280/960 = 4/3 - const currentRatio = sourceWidth / sourceHeight - if (Math.abs(currentRatio - targetRatio) > 0.01) { - // 如果比例不匹配,以较小的尺寸为基准调整 - if (currentRatio > targetRatio) { - // 当前更宽,以高度为基准 - sourceWidth = sourceHeight * targetRatio - } else { - // 当前更高,以宽度为基准 - sourceHeight = sourceWidth / targetRatio - } - } + // 步骤图片保持矩形比例,不强制为正方形 + const sourceWidth = sourceSizeX + const sourceHeight = sourceSizeY // 计算裁剪区域的起始位置 let sourceX = originalWidth / 2 + offsetInOriginalX - sourceWidth / 2 @@ -779,33 +687,6 @@ Page({ // 确保裁剪区域在图片范围内 sourceX = Math.max(0, Math.min(sourceX, originalWidth - sourceWidth)) sourceY = Math.max(0, Math.min(sourceY, originalHeight - sourceHeight)) - sourceWidth = Math.min(sourceWidth, originalWidth - sourceX) - sourceHeight = Math.min(sourceHeight, originalHeight - sourceY) - - console.log('步骤图片裁剪参数:', JSON.stringify({ - 原始图片尺寸: { originalWidth, originalHeight }, - 显示尺寸: { imageWidth, imageHeight }, - 裁剪框显示尺寸: { cropDisplayWidth, cropDisplayHeight }, - 裁剪框目标尺寸: { cropWidth, cropHeight }, - 用户缩放: scale, - 用户位移: { translateX, translateY }, - 旋转: rotation, - 比例计算: { - displayToOriginalRatioX, - displayToOriginalRatioY, - ratioX, - ratioY - }, - 裁剪区域尺寸计算: { - sourceSizeX, - sourceSizeY, - sourceWidth, - sourceHeight, - 比例: sourceWidth / sourceHeight, - 目标比例: cropWidth / cropHeight - }, - 裁剪区域: { sourceX, sourceY, sourceWidth, sourceHeight } - }, null, 2)) // 绘制裁剪后的图片 if (rotation === 0) { -- Gitee From 43ceef3e07f5dee982d7cffb65a15f7624fb330f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Wed, 12 Nov 2025 19:49:53 +0800 Subject: [PATCH 33/44] test' --- miniprogram/pages/image-edit/image-edit.js | 37 ++++++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/miniprogram/pages/image-edit/image-edit.js b/miniprogram/pages/image-edit/image-edit.js index f40275f..a3ff9f3 100644 --- a/miniprogram/pages/image-edit/image-edit.js +++ b/miniprogram/pages/image-edit/image-edit.js @@ -239,14 +239,37 @@ Page({ // 需求:短边与裁剪框一致(不限高度还是宽度) // 比较图片和裁剪框的宽高比,决定以哪一边为基准 let imageDisplayWidth, imageDisplayHeight - if (imageRatio > cropRatio) { - // 图片更宽(横向),高度与裁剪框高度一致 - imageDisplayHeight = cropDisplayHeight - imageDisplayWidth = imageDisplayHeight * imageRatio + + // 判断图片宽高和裁剪框宽高的比例 + const widthRatio = width / cropWidth; + const heightRatio = height / cropHeight; + console.log('widthRatio', widthRatio, 'heightRatio', heightRatio); + if (widthRatio > 1 && heightRatio > 1) { + // 图片比裁剪框大,哪个比例小哪个贴边 + if (widthRatio < heightRatio) { + // 宽度贴边 + imageDisplayWidth = cropDisplayWidth; + imageDisplayHeight = imageDisplayWidth / imageRatio; + } else { + // 高度贴边 + imageDisplayHeight = cropDisplayHeight; + imageDisplayWidth = imageDisplayHeight * imageRatio; + } + } else if (widthRatio > 1 || heightRatio > 1) { + // 至少有一个 > 1,取大的那边贴边(另一个超出裁剪框) + if (widthRatio > heightRatio) { + // 宽大于裁剪框,宽度贴边 + imageDisplayWidth = cropDisplayWidth; + imageDisplayHeight = imageDisplayWidth / imageRatio; + } else { + // 高大于裁剪框,高度贴边 + imageDisplayHeight = cropDisplayHeight; + imageDisplayWidth = imageDisplayHeight * imageRatio; + } } else { - // 图片更高(纵向)或等比例,宽度与裁剪框宽度一致 - imageDisplayWidth = cropDisplayWidth - imageDisplayHeight = imageDisplayWidth / imageRatio + // 两边都 <= 1,原图显示,不缩放 + imageDisplayWidth = cropDisplayWidth < width ? cropDisplayWidth : width; + imageDisplayHeight = cropDisplayHeight < height ? cropDisplayHeight : height; } // 计算裁剪框在屏幕上的位置(居中) -- Gitee From f3e1a6871419e5ed0aadc29bf0ec551c039b2c18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Thu, 13 Nov 2025 13:10:59 +0800 Subject: [PATCH 34/44] =?UTF-8?q?1.=E5=B7=B2=E5=B0=86=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E4=BB=8E=E6=97=A7=E7=9A=84=20Canvas=20API=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=20Canvas=202D=20API=E3=80=82=E4=B8=BB=E8=A6=81?= =?UTF-8?q?=E6=9B=B4=E6=94=B9=E5=A6=82=E4=B8=8B=EF=BC=9A2.=20=E6=AD=A5?= =?UTF-8?q?=E9=AA=A4=E5=9B=BE=E7=89=87=E6=97=8B=E8=BD=AC=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/app.js | 3 +- miniprogram/pages/image-edit/image-edit.js | 424 +++++++++++-------- miniprogram/pages/image-edit/image-edit.wxml | 6 +- 3 files changed, 251 insertions(+), 182 deletions(-) diff --git a/miniprogram/app.js b/miniprogram/app.js index e8e915c..6425b54 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -23,7 +23,8 @@ App({ return 'https://b106.xyz' } else { // 模拟器调试:使用localhost - return 'http://localhost:8000' + //return 'http://localhost:8000' + return 'https://b106.xyz' } } else if (__wxConfig.envVersion === 'trial') { // 体验版环境 diff --git a/miniprogram/pages/image-edit/image-edit.js b/miniprogram/pages/image-edit/image-edit.js index a3ff9f3..74b4288 100644 --- a/miniprogram/pages/image-edit/image-edit.js +++ b/miniprogram/pages/image-edit/image-edit.js @@ -109,9 +109,9 @@ Page({ const { cropWidth, cropHeight } = this.data // 获取屏幕信息 - const systemInfo = wx.getSystemInfoSync() - const screenWidth = systemInfo.windowWidth - const screenHeight = systemInfo.windowHeight + const windowInfo = wx.getWindowInfo() + const screenWidth = windowInfo.windowWidth + const screenHeight = windowInfo.windowHeight // 图片的宽高比是固定的,旋转只是CSS变换,不影响布局计算 // 始终使用原始图片的宽高比 @@ -200,13 +200,17 @@ Page({ * 计算步骤图片布局(矩形1280x960,4:3比例) */ calculateStepImageLayout(imageInfo) { - const { width, height } = imageInfo + let { width, height } = imageInfo const { cropWidth, cropHeight } = this.data - + if (this.data.rotation === 90 || this.data.rotation === 270) { + const temp = width; + width = height; + height = temp; + } // 获取屏幕信息 - const systemInfo = wx.getSystemInfoSync() - const screenWidth = systemInfo.windowWidth - const screenHeight = systemInfo.windowHeight + const windowInfo = wx.getWindowInfo() + const screenWidth = windowInfo.windowWidth + const screenHeight = windowInfo.windowHeight // 图片的宽高比是固定的,旋转只是CSS变换,不影响布局计算 // 始终使用原始图片的宽高比 @@ -241,8 +245,9 @@ Page({ let imageDisplayWidth, imageDisplayHeight // 判断图片宽高和裁剪框宽高的比例 - const widthRatio = width / cropWidth; - const heightRatio = height / cropHeight; + const widthRatio = width / cropDisplayWidth; + const heightRatio = height / cropDisplayHeight; + console.log('width', width, 'height', height, 'cropWidth', cropWidth, 'cropHeight', cropHeight); console.log('widthRatio', widthRatio, 'heightRatio', heightRatio); if (widthRatio > 1 && heightRatio > 1) { // 图片比裁剪框大,哪个比例小哪个贴边 @@ -250,10 +255,12 @@ Page({ // 宽度贴边 imageDisplayWidth = cropDisplayWidth; imageDisplayHeight = imageDisplayWidth / imageRatio; + console.log('宽度贴边', '宽度', imageDisplayWidth, '高度', imageDisplayHeight); } else { // 高度贴边 imageDisplayHeight = cropDisplayHeight; imageDisplayWidth = imageDisplayHeight * imageRatio; + console.log('高度贴边', '宽度', imageDisplayWidth, '高度', imageDisplayHeight); } } else if (widthRatio > 1 || heightRatio > 1) { // 至少有一个 > 1,取大的那边贴边(另一个超出裁剪框) @@ -261,17 +268,25 @@ Page({ // 宽大于裁剪框,宽度贴边 imageDisplayWidth = cropDisplayWidth; imageDisplayHeight = imageDisplayWidth / imageRatio; + console.log('宽度贴边,高度小于裁切框', '宽度', imageDisplayWidth, '高度', imageDisplayHeight); } else { // 高大于裁剪框,高度贴边 imageDisplayHeight = cropDisplayHeight; imageDisplayWidth = imageDisplayHeight * imageRatio; + console.log('高度贴边,宽度小于裁切框', '宽度', imageDisplayWidth, '高度', imageDisplayHeight); } } else { // 两边都 <= 1,原图显示,不缩放 imageDisplayWidth = cropDisplayWidth < width ? cropDisplayWidth : width; imageDisplayHeight = cropDisplayHeight < height ? cropDisplayHeight : height; + console.log('原图显示', '宽度', imageDisplayWidth, '高度', imageDisplayHeight); + } + if (this.data.rotation === 90 || this.data.rotation === 270) { + const temp = imageDisplayWidth; + imageDisplayWidth = imageDisplayHeight; + imageDisplayHeight = temp; + console.log('旋转后显示尺寸', '宽度', imageDisplayWidth, '高度', imageDisplayHeight); } - // 计算裁剪框在屏幕上的位置(居中) const cropTop = (screenHeight - cropDisplayHeight) / 2 const cropLeft = (screenWidth - cropDisplayWidth) / 2 @@ -286,8 +301,7 @@ Page({ const scaleX = cropDisplayWidth / imageDisplayWidth const scaleY = cropDisplayHeight / imageDisplayHeight const minScale = Math.max(scaleX, scaleY, 1.0) // 至少为1.0,确保能覆盖裁剪框 - const finalMinScale = minScale - + console.log('最小缩放比例', minScale); this.setData({ originalWidth: width, originalHeight: height, @@ -300,7 +314,7 @@ Page({ cropTop: cropTop, cropLeft: cropLeft, scale: 1, - minScale: finalMinScale, + minScale: minScale, maxScale: 3, translateX: 0, translateY: 0 @@ -555,89 +569,115 @@ Page({ cropWidth, cropHeight, cropDisplayWidth, cropDisplayHeight, cropTop, cropLeft, scale, translateX, translateY, rotation } = this.data - // 创建画布上下文用于最终输出 - const finalCtx = wx.createCanvasContext('finalCanvas', this) - // 获取屏幕尺寸(用于计算旋转后的坐标转换) - const screenWidth = wx.getSystemInfoSync().windowWidth - const screenHeight = wx.getSystemInfoSync().windowHeight - - // 计算图片中心点(在屏幕上的位置) - const imageCenterX = imageLeft + imageWidth / 2 - const imageCenterY = imageTop + imageHeight / 2 - - // 计算裁剪框中心点(在屏幕上的位置) - const cropCenterX = cropLeft + cropDisplayWidth / 2 - const cropCenterY = cropTop + cropDisplayHeight / 2 - - // 将裁剪框中心点相对于图片中心点的偏移(在屏幕坐标系下) - let screenOffsetX = cropCenterX - imageCenterX - translateX - let screenOffsetY = cropCenterY - imageCenterY - translateY - - // 将屏幕坐标系的偏移转换为原始图片坐标系的偏移 - let offsetX, offsetY - if (rotation === 90) { - offsetX = screenOffsetY - offsetY = -screenOffsetX - } else if (rotation === 180) { - offsetX = -screenOffsetX - offsetY = -screenOffsetY - } else if (rotation === 270) { - offsetX = -screenOffsetY - offsetY = screenOffsetX - } else { - offsetX = screenOffsetX - offsetY = screenOffsetY - } - - // 计算比例 - const displayToOriginalRatioX = originalWidth / imageWidth - const displayToOriginalRatioY = originalHeight / imageHeight - - const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatioX - const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatioY - - // 计算裁剪区域的尺寸(菜品图片是正方形) - const ratioX = originalWidth / imageWidth - const ratioY = originalHeight / imageHeight - const sourceSizeX = (cropDisplayWidth / scale) * ratioX - const sourceSizeY = (cropDisplayHeight / scale) * ratioY - - // 取较小值,确保裁剪区域是正方形且不超出图片范围 - const sourceSize = Math.min(sourceSizeX, sourceSizeY) - const sourceWidth = sourceSize - const sourceHeight = sourceSize - - // 计算裁剪区域的起始位置 - let sourceX = originalWidth / 2 + offsetInOriginalX - sourceWidth / 2 - let sourceY = originalHeight / 2 + offsetInOriginalY - sourceHeight / 2 - - // 确保裁剪区域在图片范围内 - sourceX = Math.max(0, Math.min(sourceX, originalWidth - sourceWidth)) - sourceY = Math.max(0, Math.min(sourceY, originalHeight - sourceHeight)) - - // 绘制裁剪后的图片 - if (rotation === 0) { - finalCtx.drawImage( - src, - sourceX, sourceY, sourceWidth, sourceHeight, - 0, 0, cropWidth, cropHeight - ) - } else { - finalCtx.save() - finalCtx.translate(cropWidth / 2, cropHeight / 2) - finalCtx.rotate(rotation * Math.PI / 180) - finalCtx.drawImage( - src, - sourceX, sourceY, sourceWidth, sourceHeight, - -cropWidth / 2, -cropHeight / 2, cropWidth, cropHeight - ) - finalCtx.restore() - } - - finalCtx.draw(false, () => { - this.exportCanvas() - }) + const systemInfo = wx.getSystemInfoSync() + const screenWidth = systemInfo.windowWidth + const screenHeight = systemInfo.windowHeight + const dpr = systemInfo.pixelRatio || 1 + + // 使用 Canvas 2D API 获取画布上下文 + wx.createSelectorQuery() + .select('#finalCanvas') + .fields({ node: true, size: true }) + .exec((res) => { + const canvas = res[0].node + const ctx = canvas.getContext('2d') + + // 设置 canvas 实际像素尺寸 + const canvasWidth = cropWidth + const canvasHeight = cropHeight + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + + // 缩放上下文以匹配设备像素比 + ctx.scale(dpr, dpr) + + // 计算图片中心点(在屏幕上的位置) + const imageCenterX = imageLeft + imageWidth / 2 + const imageCenterY = imageTop + imageHeight / 2 + + // 计算裁剪框中心点(在屏幕上的位置) + const cropCenterX = cropLeft + cropDisplayWidth / 2 + const cropCenterY = cropTop + cropDisplayHeight / 2 + + // 将裁剪框中心点相对于图片中心点的偏移(在屏幕坐标系下) + let screenOffsetX = cropCenterX - imageCenterX - translateX + let screenOffsetY = cropCenterY - imageCenterY - translateY + + // 将屏幕坐标系的偏移转换为原始图片坐标系的偏移 + let offsetX, offsetY + if (rotation === 90) { + offsetX = screenOffsetY + offsetY = -screenOffsetX + } else if (rotation === 180) { + offsetX = -screenOffsetX + offsetY = -screenOffsetY + } else if (rotation === 270) { + offsetX = -screenOffsetY + offsetY = screenOffsetX + } else { + offsetX = screenOffsetX + offsetY = screenOffsetY + } + + // 计算比例 + const displayToOriginalRatioX = originalWidth / imageWidth + const displayToOriginalRatioY = originalHeight / imageHeight + + const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatioX + const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatioY + + // 计算裁剪区域的尺寸(菜品图片是正方形) + const ratioX = originalWidth / imageWidth + const ratioY = originalHeight / imageHeight + const sourceSizeX = (cropDisplayWidth / scale) * ratioX + const sourceSizeY = (cropDisplayHeight / scale) * ratioY + + // 取较小值,确保裁剪区域是正方形且不超出图片范围 + const sourceSize = Math.min(sourceSizeX, sourceSizeY) + const sourceWidth = sourceSize + const sourceHeight = sourceSize + + // 计算裁剪区域的起始位置 + let sourceX = originalWidth / 2 + offsetInOriginalX - sourceWidth / 2 + let sourceY = originalHeight / 2 + offsetInOriginalY - sourceHeight / 2 + + // 确保裁剪区域在图片范围内 + sourceX = Math.max(0, Math.min(sourceX, originalWidth - sourceWidth)) + sourceY = Math.max(0, Math.min(sourceY, originalHeight - sourceHeight)) + + // 加载图片并绘制 + const img = canvas.createImage() + img.onload = () => { + // 绘制裁剪后的图片 + if (rotation === 0) { + ctx.drawImage( + img, + sourceX, sourceY, sourceWidth, sourceHeight, + 0, 0, cropWidth, cropHeight + ) + } else { + ctx.save() + ctx.translate(cropWidth / 2, cropHeight / 2) + ctx.rotate(rotation * Math.PI / 180) + ctx.drawImage( + img, + sourceX, sourceY, sourceWidth, sourceHeight, + -cropWidth / 2, -cropHeight / 2, cropWidth, cropHeight + ) + ctx.restore() + } + + // Canvas 2D API 不需要调用 draw,直接导出 + this.exportCanvas(canvas) + } + img.onerror = (err) => { + console.error('图片加载失败:', err) + hideLoading() + showError('图片加载失败') + } + img.src = src + }) }, /** @@ -651,98 +691,124 @@ Page({ cropWidth, cropHeight, cropDisplayWidth, cropDisplayHeight, cropTop, cropLeft, scale, translateX, translateY, rotation } = this.data - // 创建画布上下文用于最终输出 - const finalCtx = wx.createCanvasContext('finalCanvas', this) - // 获取屏幕尺寸(用于计算旋转后的坐标转换) - const screenWidth = wx.getSystemInfoSync().windowWidth - const screenHeight = wx.getSystemInfoSync().windowHeight - - // 计算图片中心点(在屏幕上的位置) - const imageCenterX = imageLeft + imageWidth / 2 - const imageCenterY = imageTop + imageHeight / 2 - - // 计算裁剪框中心点(在屏幕上的位置) - const cropCenterX = cropLeft + cropDisplayWidth / 2 - const cropCenterY = cropTop + cropDisplayHeight / 2 - - // 将裁剪框中心点相对于图片中心点的偏移(在屏幕坐标系下) - let screenOffsetX = cropCenterX - imageCenterX - translateX - let screenOffsetY = cropCenterY - imageCenterY - translateY - - // 将屏幕坐标系的偏移转换为原始图片坐标系的偏移 - let offsetX, offsetY - if (rotation === 90) { - offsetX = screenOffsetY - offsetY = -screenOffsetX - } else if (rotation === 180) { - offsetX = -screenOffsetX - offsetY = -screenOffsetY - } else if (rotation === 270) { - offsetX = -screenOffsetY - offsetY = screenOffsetX - } else { - offsetX = screenOffsetX - offsetY = screenOffsetY - } - - // 计算比例 - const displayToOriginalRatioX = originalWidth / imageWidth - const displayToOriginalRatioY = originalHeight / imageHeight - - const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatioX - const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatioY - - // 计算裁剪区域的尺寸(步骤图片是矩形,保持4:3比例) - const ratioX = originalWidth / imageWidth - const ratioY = originalHeight / imageHeight - const sourceSizeX = (cropDisplayWidth / scale) * ratioX - const sourceSizeY = (cropDisplayHeight / scale) * ratioY - - // 步骤图片保持矩形比例,不强制为正方形 - const sourceWidth = sourceSizeX - const sourceHeight = sourceSizeY - - // 计算裁剪区域的起始位置 - let sourceX = originalWidth / 2 + offsetInOriginalX - sourceWidth / 2 - let sourceY = originalHeight / 2 + offsetInOriginalY - sourceHeight / 2 - - // 确保裁剪区域在图片范围内 - sourceX = Math.max(0, Math.min(sourceX, originalWidth - sourceWidth)) - sourceY = Math.max(0, Math.min(sourceY, originalHeight - sourceHeight)) - - // 绘制裁剪后的图片 - if (rotation === 0) { - finalCtx.drawImage( - src, - sourceX, sourceY, sourceWidth, sourceHeight, - 0, 0, cropWidth, cropHeight - ) - } else { - finalCtx.save() - finalCtx.translate(cropWidth / 2, cropHeight / 2) - finalCtx.rotate(rotation * Math.PI / 180) - finalCtx.drawImage( - src, - sourceX, sourceY, sourceWidth, sourceHeight, - -cropWidth / 2, -cropHeight / 2, cropWidth, cropHeight - ) - finalCtx.restore() - } - - finalCtx.draw(false, () => { - this.exportCanvas() - }) + const systemInfo = wx.getSystemInfoSync() + const screenWidth = systemInfo.windowWidth + const screenHeight = systemInfo.windowHeight + const dpr = systemInfo.pixelRatio || 1 + + // 使用 Canvas 2D API 获取画布上下文 + wx.createSelectorQuery() + .select('#finalCanvas') + .fields({ node: true, size: true }) + .exec((res) => { + const canvas = res[0].node + const ctx = canvas.getContext('2d') + + // 设置 canvas 实际像素尺寸 + const canvasWidth = cropWidth + const canvasHeight = cropHeight + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + + // 缩放上下文以匹配设备像素比 + ctx.scale(dpr, dpr) + + // 计算图片中心点(在屏幕上的位置) + const imageCenterX = imageLeft + imageWidth / 2 + const imageCenterY = imageTop + imageHeight / 2 + + // 计算裁剪框中心点(在屏幕上的位置) + const cropCenterX = cropLeft + cropDisplayWidth / 2 + const cropCenterY = cropTop + cropDisplayHeight / 2 + + // 将裁剪框中心点相对于图片中心点的偏移(在屏幕坐标系下) + let screenOffsetX = cropCenterX - imageCenterX - translateX + let screenOffsetY = cropCenterY - imageCenterY - translateY + + // 将屏幕坐标系的偏移转换为原始图片坐标系的偏移 + let offsetX, offsetY + if (rotation === 90) { + offsetX = screenOffsetY + offsetY = -screenOffsetX + } else if (rotation === 180) { + offsetX = -screenOffsetX + offsetY = -screenOffsetY + } else if (rotation === 270) { + offsetX = -screenOffsetY + offsetY = screenOffsetX + } else { + offsetX = screenOffsetX + offsetY = screenOffsetY + } + + // 计算比例 + const displayToOriginalRatioX = originalWidth / imageWidth + const displayToOriginalRatioY = originalHeight / imageHeight + + const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatioX + const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatioY + + // 计算裁剪区域的尺寸(步骤图片是矩形,保持4:3比例) + const ratioX = originalWidth / imageWidth + const ratioY = originalHeight / imageHeight + const sourceSizeX = (cropDisplayWidth / scale) * ratioX + const sourceSizeY = (cropDisplayHeight / scale) * ratioY + + // 步骤图片保持矩形比例,不强制为正方形 + const sourceWidth = sourceSizeX + const sourceHeight = sourceSizeY + + // 计算裁剪区域的起始位置 + let sourceX = originalWidth / 2 + offsetInOriginalX - sourceWidth / 2 + let sourceY = originalHeight / 2 + offsetInOriginalY - sourceHeight / 2 + + // 确保裁剪区域在图片范围内 + sourceX = Math.max(0, Math.min(sourceX, originalWidth - sourceWidth)) + sourceY = Math.max(0, Math.min(sourceY, originalHeight - sourceHeight)) + + // 加载图片并绘制 + const img = canvas.createImage() + img.onload = () => { + // 绘制裁剪后的图片 + if (rotation === 0) { + ctx.drawImage( + img, + sourceX, sourceY, sourceWidth, sourceHeight, + 0, 0, cropWidth, cropHeight + ) + } else { + ctx.save() + ctx.translate(cropWidth / 2, cropHeight / 2) + ctx.rotate(rotation * Math.PI / 180) + ctx.drawImage( + img, + sourceX, sourceY, sourceWidth, sourceHeight, + -cropWidth / 2, -cropHeight / 2, cropWidth, cropHeight + ) + ctx.restore() + } + + // Canvas 2D API 不需要调用 draw,直接导出 + this.exportCanvas(canvas) + } + img.onerror = (err) => { + console.error('图片加载失败:', err) + hideLoading() + showError('图片加载失败') + } + img.src = src + }) }, /** * 导出画布 */ - exportCanvas() { + exportCanvas(canvas) { const { cropWidth, cropHeight } = this.data wx.canvasToTempFilePath({ - canvasId: 'finalCanvas', + canvas: canvas, // Canvas 2D API 使用 canvas 节点 fileType: 'jpg', // 使用JPEG格式,文件更小 quality: 0.85, // 压缩质量 0-1,0.85是较好的平衡 destWidth: cropWidth, // 指定输出宽度 diff --git a/miniprogram/pages/image-edit/image-edit.wxml b/miniprogram/pages/image-edit/image-edit.wxml index b17dc33..b56e7fd 100644 --- a/miniprogram/pages/image-edit/image-edit.wxml +++ b/miniprogram/pages/image-edit/image-edit.wxml @@ -31,12 +31,14 @@ -- Gitee From cd523909fe1e235380f0a0052443477aca8d24d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Thu, 13 Nov 2025 13:15:18 +0800 Subject: [PATCH 35/44] =?UTF-8?q?=E6=9B=BF=E6=8D=A2=20wx.getSystemInfoSync?= =?UTF-8?q?()=EF=BC=9A=20=E5=9C=A8=20confirmEditDish=20=E5=92=8C=20confirm?= =?UTF-8?q?EditStep=20=E6=96=B9=E6=B3=95=E4=B8=AD=20=E4=BD=BF=E7=94=A8=20w?= =?UTF-8?q?x.getWindowInfo()=20=E8=8E=B7=E5=8F=96=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=EF=BC=88windowWidth=E3=80=81windowHeight?= =?UTF-8?q?=EF=BC=89=20=E4=BD=BF=E7=94=A8=20wx.getDeviceInfo()=20=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E8=AE=BE=E5=A4=87=E4=BF=A1=E6=81=AF=EF=BC=88pixelRati?= =?UTF-8?q?o=EF=BC=89=20=E6=9B=BF=E6=8D=A2=20wx.getFileInfo()=EF=BC=9A=20?= =?UTF-8?q?=E5=9C=A8=20exportCanvas=20=E6=96=B9=E6=B3=95=E4=B8=AD=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20wx.getFileSystemManager().getFileInfo()=20?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/pages/image-edit/image-edit.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/miniprogram/pages/image-edit/image-edit.js b/miniprogram/pages/image-edit/image-edit.js index 74b4288..ac09adc 100644 --- a/miniprogram/pages/image-edit/image-edit.js +++ b/miniprogram/pages/image-edit/image-edit.js @@ -570,10 +570,11 @@ Page({ scale, translateX, translateY, rotation } = this.data // 获取屏幕尺寸(用于计算旋转后的坐标转换) - const systemInfo = wx.getSystemInfoSync() - const screenWidth = systemInfo.windowWidth - const screenHeight = systemInfo.windowHeight - const dpr = systemInfo.pixelRatio || 1 + const windowInfo = wx.getWindowInfo() + const deviceInfo = wx.getDeviceInfo() + const screenWidth = windowInfo.windowWidth + const screenHeight = windowInfo.windowHeight + const dpr = deviceInfo.pixelRatio || 1 // 使用 Canvas 2D API 获取画布上下文 wx.createSelectorQuery() @@ -692,10 +693,11 @@ Page({ scale, translateX, translateY, rotation } = this.data // 获取屏幕尺寸(用于计算旋转后的坐标转换) - const systemInfo = wx.getSystemInfoSync() - const screenWidth = systemInfo.windowWidth - const screenHeight = systemInfo.windowHeight - const dpr = systemInfo.pixelRatio || 1 + const windowInfo = wx.getWindowInfo() + const deviceInfo = wx.getDeviceInfo() + const screenWidth = windowInfo.windowWidth + const screenHeight = windowInfo.windowHeight + const dpr = deviceInfo.pixelRatio || 1 // 使用 Canvas 2D API 获取画布上下文 wx.createSelectorQuery() @@ -817,7 +819,8 @@ Page({ console.log('图片处理成功:', res.tempFilePath) // 进一步压缩图片(如果文件仍然很大) - wx.getFileInfo({ + const fileSystemManager = wx.getFileSystemManager() + fileSystemManager.getFileInfo({ filePath: res.tempFilePath, success: (fileInfo) => { console.log('导出图片大小:', fileInfo.size, '字节') -- Gitee From 5e36866f3b1dd95bd947c4ad95b69bd64e51b144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Thu, 13 Nov 2025 15:11:16 +0800 Subject: [PATCH 36/44] test --- miniprogram/pages/image-edit/image-edit.js | 44 ++++++++++++++-------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/miniprogram/pages/image-edit/image-edit.js b/miniprogram/pages/image-edit/image-edit.js index ac09adc..682f66d 100644 --- a/miniprogram/pages/image-edit/image-edit.js +++ b/miniprogram/pages/image-edit/image-edit.js @@ -577,6 +577,8 @@ Page({ const dpr = deviceInfo.pixelRatio || 1 // 使用 Canvas 2D API 获取画布上下文 + // 注意:可能会在控制台看到 SharedArrayBuffer 的弃用警告,这是浏览器/开发者工具的警告, + // 不影响微信小程序的实际功能,可以安全忽略 wx.createSelectorQuery() .select('#finalCanvas') .fields({ node: true, size: true }) @@ -621,10 +623,8 @@ Page({ offsetY = screenOffsetY } - // 计算比例 - const displayToOriginalRatioX = originalWidth / imageWidth - const displayToOriginalRatioY = originalHeight / imageHeight - + const displayToOriginalRatioX = originalHeight / imageWidth + const displayToOriginalRatioY = originalWidth / imageHeight const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatioX const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatioY @@ -700,6 +700,8 @@ Page({ const dpr = deviceInfo.pixelRatio || 1 // 使用 Canvas 2D API 获取画布上下文 + // 注意:可能会在控制台看到 SharedArrayBuffer 的弃用警告,这是浏览器/开发者工具的警告, + // 不影响微信小程序的实际功能,可以安全忽略 wx.createSelectorQuery() .select('#finalCanvas') .fields({ node: true, size: true }) @@ -743,19 +745,24 @@ Page({ offsetX = screenOffsetX offsetY = screenOffsetY } - + + let displayToOriginalRatioX, displayToOriginalRatioY // 计算比例 - const displayToOriginalRatioX = originalWidth / imageWidth - const displayToOriginalRatioY = originalHeight / imageHeight - + if (rotation === 90 || rotation === 270 ) { + displayToOriginalRatioX = originalHeight / imageWidth + displayToOriginalRatioY = originalWidth / imageHeight + } else { + displayToOriginalRatioX = originalWidth / imageWidth + displayToOriginalRatioY = originalHeight / imageHeight + } + if (displayToOriginalRatioX !== displayToOriginalRatioY) { + console.error('缩放比例不一致,无法裁切') + } const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatioX const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatioY - // 计算裁剪区域的尺寸(步骤图片是矩形,保持4:3比例) - const ratioX = originalWidth / imageWidth - const ratioY = originalHeight / imageHeight - const sourceSizeX = (cropDisplayWidth / scale) * ratioX - const sourceSizeY = (cropDisplayHeight / scale) * ratioY + const sourceSizeX = (cropDisplayWidth / scale) * displayToOriginalRatioX + const sourceSizeY = (cropDisplayHeight / scale) * displayToOriginalRatioY // 步骤图片保持矩形比例,不强制为正方形 const sourceWidth = sourceSizeX @@ -764,11 +771,18 @@ Page({ // 计算裁剪区域的起始位置 let sourceX = originalWidth / 2 + offsetInOriginalX - sourceWidth / 2 let sourceY = originalHeight / 2 + offsetInOriginalY - sourceHeight / 2 - + console.log('步骤图片布局计算完成:', JSON.stringify({ + 屏幕尺寸: { screenWidth, screenHeight }, + 图片原始尺寸: { originalWidth, originalHeight }, + 显示尺寸: { imageWidth, imageHeight }, + 比例:{ scale }, + 显示比例:{displayToOriginalRatioX, displayToOriginalRatioY }, + 裁切起始位置: { sourceX, sourceY }, + 裁切尺寸: { sourceSizeX, sourceSizeY } + }, null, 2)) // 确保裁剪区域在图片范围内 sourceX = Math.max(0, Math.min(sourceX, originalWidth - sourceWidth)) sourceY = Math.max(0, Math.min(sourceY, originalHeight - sourceHeight)) - // 加载图片并绘制 const img = canvas.createImage() img.onload = () => { -- Gitee From 54db5f75419554ed0ff89e0ea7f78f09a6f3e14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Tue, 18 Nov 2025 10:53:07 +0800 Subject: [PATCH 37/44] =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E5=BA=93?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=88=B03.8.12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/pages/chef/dish-edit/dish-edit.js | 9 ++--- .../pages/chef/meal-set-edit/meal-set-edit.js | 4 +- miniprogram/pages/chef/schedule/schedule.js | 2 +- .../pages/chef/shopping-list/shopping-list.js | 15 +++---- miniprogram/pages/gourmet/chefs/chefs.js | 6 +-- .../pages/gourmet/dish-detail/dish-detail.js | 3 +- .../gourmet/dish-selector/dish-selector.js | 40 +++++++++---------- .../gourmet/menu-browser/menu-browser.js | 4 +- miniprogram/pages/plan/plan.js | 6 +-- miniprogram/project.private.config.json | 2 +- miniprogram/utils/imageManager.js | 8 ++-- miniprogram/utils/request.js | 9 ++--- miniprogram/utils/util.js | 6 ++- miniprogram/utils/version.js | 5 +-- 14 files changed, 56 insertions(+), 63 deletions(-) diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.js b/miniprogram/pages/chef/dish-edit/dish-edit.js index 2a83634..9dde61a 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.js +++ b/miniprogram/pages/chef/dish-edit/dish-edit.js @@ -137,11 +137,10 @@ Page({ // 初始化食材的单位选择器索引 const ingredients = (res.ingredients || []).map(ingredient => { const unitIndex = this.data.commonUnits.indexOf(ingredient.unit) - return { - ...ingredient, + return Object.assign({}, ingredient, { unitIndex: unitIndex >= 0 ? unitIndex : 0, customUnit: unitIndex < 0 - } + }) }) // 处理营养成分分类(从数组格式转换为code数组) @@ -203,7 +202,7 @@ Page({ */ toggleNutritionCategory(e) { const category = e.currentTarget.dataset.category - const nutritionCategories = [...this.data.formData.nutrition_categories] + const nutritionCategories = this.data.formData.nutrition_categories.slice() const index = nutritionCategories.indexOf(category) if (index > -1) { @@ -705,7 +704,7 @@ Page({ // 验证食材名称不重复 const ingredientNames = validIngredients.map(ing => ing.name.trim()) - const uniqueNames = [...new Set(ingredientNames)] + const uniqueNames = Array.from(new Set(ingredientNames)) if (ingredientNames.length !== uniqueNames.length) { errors.ingredients = '食材名称不能重复' } diff --git a/miniprogram/pages/chef/meal-set-edit/meal-set-edit.js b/miniprogram/pages/chef/meal-set-edit/meal-set-edit.js index 752110c..8c5a0df 100644 --- a/miniprogram/pages/chef/meal-set-edit/meal-set-edit.js +++ b/miniprogram/pages/chef/meal-set-edit/meal-set-edit.js @@ -96,7 +96,7 @@ Page({ const dish = this.data.dishes.find(d => d.id === dishId) if (!dish) return - let dishIds = [...this.data.formData.dish_ids] + let dishIds = this.data.formData.dish_ids.slice() const index = dishIds.indexOf(dishId) if (index > -1) { @@ -132,7 +132,7 @@ Page({ const selectedDishes = this.data.dishes.filter(dish => formData.dish_ids.includes(dish.id) ) - const selectedCategories = [...new Set(selectedDishes.map(dish => dish.category))] + const selectedCategories = Array.from(new Set(selectedDishes.map(dish => dish.category))) // 检查是否包含所有必需分类 const missingCategories = requiredCategories.filter(cat => diff --git a/miniprogram/pages/chef/schedule/schedule.js b/miniprogram/pages/chef/schedule/schedule.js index 72d9eaf..8da99fb 100644 --- a/miniprogram/pages/chef/schedule/schedule.js +++ b/miniprogram/pages/chef/schedule/schedule.js @@ -84,7 +84,7 @@ Page({ // 切换食神选择 toggleGourmet(e) { const gourmetId = e.currentTarget.dataset.id - let selectedIds = [...this.data.selectedGourmetIds] + let selectedIds = this.data.selectedGourmetIds.slice() const index = selectedIds.indexOf(gourmetId) if (index > -1) { diff --git a/miniprogram/pages/chef/shopping-list/shopping-list.js b/miniprogram/pages/chef/shopping-list/shopping-list.js index c6c4cec..dd4d4b5 100644 --- a/miniprogram/pages/chef/shopping-list/shopping-list.js +++ b/miniprogram/pages/chef/shopping-list/shopping-list.js @@ -121,8 +121,7 @@ Page({ const selectedIds = selectAll ? this.data.gourmets.map(g => g.id) : [] // 更新每个 gourmet 的 checked 状态 - const gourmets = this.data.gourmets.map(gourmet => ({ - ...gourmet, + const gourmets = this.data.gourmets.map(gourmet => Object.assign({}, gourmet, { checked: selectedIds.includes(gourmet.id) })) @@ -136,7 +135,7 @@ Page({ // 切换食神选择 toggleGourmet(e) { const gourmetId = parseInt(e.currentTarget.dataset.id) // 确保类型一致 - let selectedIds = [...this.data.selectedGourmetIds] + let selectedIds = this.data.selectedGourmetIds.slice() const index = selectedIds.indexOf(gourmetId) if (index > -1) { @@ -146,8 +145,7 @@ Page({ } // 更新每个 gourmet 的 checked 状态 - const gourmets = this.data.gourmets.map(gourmet => ({ - ...gourmet, + const gourmets = this.data.gourmets.map(gourmet => Object.assign({}, gourmet, { checked: selectedIds.includes(gourmet.id) })) @@ -182,7 +180,7 @@ Page({ // 切换餐别选择 toggleMealType(e) { const mealType = e.currentTarget.dataset.type - let mealTypes = [...this.data.mealTypes] + let mealTypes = this.data.mealTypes.slice() const index = mealTypes.indexOf(mealType) if (index > -1) { @@ -239,10 +237,9 @@ Page({ params.meal_types = this.data.mealTypes } - console.log('[ShoppingList] 请求参数', { - ...params, + console.log('[ShoppingList] 请求参数', Object.assign({}, params, { timestamp: new Date().toISOString() - }) + })) get('/api/gourmet/chef/shopping-list/', params) .then(res => { diff --git a/miniprogram/pages/gourmet/chefs/chefs.js b/miniprogram/pages/gourmet/chefs/chefs.js index 88c7df1..dee221b 100644 --- a/miniprogram/pages/gourmet/chefs/chefs.js +++ b/miniprogram/pages/gourmet/chefs/chefs.js @@ -72,7 +72,7 @@ Page({ // 添加搜索历史 addSearchHistory(keyword) { - let history = [...this.data.searchHistory] + let history = this.data.searchHistory.slice() const index = history.indexOf(keyword) if (index > -1) { @@ -155,7 +155,7 @@ Page({ updateChefStatus(chefId) { const chefs = this.data.chefs.map(chef => { if (chef.id === chefId) { - return { ...chef, binding_status: 'pending' } + return Object.assign({}, chef, { binding_status: 'pending' }) } return chef }) @@ -181,7 +181,7 @@ Page({ if (chefId) { const chefs = this.data.chefs.map(chef => { if (chef.id === chefId) { - return { ...chef, binding_status: null } + return Object.assign({}, chef, { binding_status: null }) } return chef }) diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.js b/miniprogram/pages/gourmet/dish-detail/dish-detail.js index fc3d7d4..6b50b63 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.js +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.js @@ -224,7 +224,8 @@ Page({ } // 将当前图片放在第一位 - const urls = [url, ...allUrls.filter(u => u !== url)] + const filteredUrls = allUrls.filter(u => u !== url) + const urls = [url].concat(filteredUrls) wx.previewImage({ current: url, diff --git a/miniprogram/pages/gourmet/dish-selector/dish-selector.js b/miniprogram/pages/gourmet/dish-selector/dish-selector.js index bc5a355..5261c87 100644 --- a/miniprogram/pages/gourmet/dish-selector/dish-selector.js +++ b/miniprogram/pages/gourmet/dish-selector/dish-selector.js @@ -123,8 +123,7 @@ Page({ loadDishTypes() { get('/api/dishes/dish-type-choices/') .then(res => { - const categoryList = (res.dish_types || []).map(item => ({ - ...item, + const categoryList = (res.dish_types || []).map(item => Object.assign({}, item, { selected: false })) const categoryNames = {} @@ -211,7 +210,7 @@ Page({ const allDishIds = [] Object.values(selectedDishes).forEach(dishes => { if (Array.isArray(dishes)) { - allDishIds.push(...dishes) + allDishIds.push.apply(allDishIds, dishes) } else { // 兼容旧数据格式(单个ID) allDishIds.push(dishes) @@ -262,8 +261,7 @@ Page({ // 检查是否已选中 const isSelected = this.isDishSelected(dish.id) - return { - ...dish, + return Object.assign({}, dish, { // 确保 id 存在 id: dish.id || dish.pk || dish.dish_id, // 使用 dish_type 作为分类(菜品类型),如果没有则使用返回的 category @@ -272,7 +270,7 @@ Page({ main_image: dish.main_image || (dish.images && dish.images.length > 0 ? dish.images[0].image : null), // 标记选中状态 isSelected - } + }) }) allDishes = allDishes.concat(categoryDishes) } else { @@ -388,7 +386,7 @@ Page({ const selectedDishIds = this.flattenSelectedDishes(selectedDishes) const displayDishes = this.data.displayDishes.map(dish => { const isSelected = selectedDishIds.includes(dish.id) - return { ...dish, isSelected } + return Object.assign({}, dish, { isSelected }) }) // 重新排序:已选中的排在最前面 @@ -413,13 +411,13 @@ Page({ // 切换分类选择 toggleCategory(e) { const category = e.currentTarget.dataset.category - const selectedCategories = [...this.data.selectedCategories] + const selectedCategories = this.data.selectedCategories.slice() const index = selectedCategories.indexOf(category) // 更新分类按钮的选中状态 const categoryList = this.data.categoryList.map(item => { if (item.value === category) { - return { ...item, selected: index === -1 } + return Object.assign({}, item, { selected: index === -1 }) } return item }) @@ -468,7 +466,7 @@ Page({ dishes.forEach(dish => { const isSelected = selectedDishIds.includes(dish.id) - const dishWithSelection = { ...dish, isSelected } + const dishWithSelection = Object.assign({}, dish, { isSelected }) if (isSelected) { selectedDishes.push(dishWithSelection) @@ -478,7 +476,7 @@ Page({ }) // 已选中的排在最前面 - return [...selectedDishes, ...unselectedDishes] + return selectedDishes.concat(unselectedDishes) }, // 选择菜品 @@ -535,7 +533,7 @@ Page({ const finalDishId = dishId const finalCategory = category2 - const selectedDishes = { ...this.data.selectedDishes } + const selectedDishes = Object.assign({}, this.data.selectedDishes) // 确保分类字段是数组 if (!selectedDishes[finalCategory]) { @@ -561,10 +559,10 @@ Page({ const updatedDishIds = this.flattenSelectedDishes(selectedDishes) const displayDishes = this.data.displayDishes.map(dish => { if (dish.id === finalDishId) { - return { ...dish, isSelected: !isSelected } + return Object.assign({}, dish, { isSelected: !isSelected }) } // 更新所有菜品的选中状态 - return { ...dish, isSelected: updatedDishIds.includes(dish.id) } + return Object.assign({}, dish, { isSelected: updatedDishIds.includes(dish.id) }) }) // 重新排序:已选中的排在最前面 @@ -668,17 +666,17 @@ Page({ if (this.isDishSelected(dishId)) { // 确保菜品在显示列表中(即使被筛选掉了) const updatedDishIds = this.flattenSelectedDishes(this.data.selectedDishes) - let displayDishes = [...this.data.displayDishes] + let displayDishes = this.data.displayDishes.slice() // 检查菜品是否在显示列表中 const dishIndex = displayDishes.findIndex(d => d.id === dishId) if (dishIndex === -1) { // 如果不在显示列表中,添加到最前面 - const dishWithSelection = { ...dish, isSelected: true } + const dishWithSelection = Object.assign({}, dish, { isSelected: true }) displayDishes.unshift(dishWithSelection) } else { // 如果在列表中,确保选中状态正确 - displayDishes[dishIndex] = { ...displayDishes[dishIndex], isSelected: true } + displayDishes[dishIndex] = Object.assign({}, displayDishes[dishIndex], { isSelected: true }) } // 重新排序:已选中的排在最前面(确保预设菜品在最前面) @@ -699,7 +697,7 @@ Page({ } // 选中这个菜品 - const selectedDishes = { ...this.data.selectedDishes } + const selectedDishes = Object.assign({}, this.data.selectedDishes) // 使用 dish_type 作为分类(菜品类型) const category = dish.dish_type || dish.category @@ -713,17 +711,17 @@ Page({ // 更新显示列表中的选中状态,并重新排序 const updatedDishIds = this.flattenSelectedDishes(selectedDishes) - let displayDishes = [...this.data.displayDishes] + let displayDishes = this.data.displayDishes.slice() // 检查菜品是否在显示列表中 const dishIndex = displayDishes.findIndex(d => d.id === dishId) if (dishIndex === -1) { // 如果不在显示列表中,添加到最前面 - const dishWithSelection = { ...dish, isSelected: true } + const dishWithSelection = Object.assign({}, dish, { isSelected: true }) displayDishes.unshift(dishWithSelection) } else { // 更新选中状态 - displayDishes[dishIndex] = { ...displayDishes[dishIndex], isSelected: true } + displayDishes[dishIndex] = Object.assign({}, displayDishes[dishIndex], { isSelected: true }) } // 重新排序:已选中的排在最前面(确保预设菜品在最前面) diff --git a/miniprogram/pages/gourmet/menu-browser/menu-browser.js b/miniprogram/pages/gourmet/menu-browser/menu-browser.js index a9bf541..69b32fb 100644 --- a/miniprogram/pages/gourmet/menu-browser/menu-browser.js +++ b/miniprogram/pages/gourmet/menu-browser/menu-browser.js @@ -83,7 +83,7 @@ Page({ const chefs = [{ id: '', nickname: '全部厨神' }] // 这里需要从绑定关系获取厨神信息 this.loadChefs().then(chefList => { - chefs.push(...chefList) + chefs.push.apply(chefs, chefList) this.setData({ dishes, mealSets, @@ -344,7 +344,7 @@ Page({ // 切换收藏状态 toggleFavorite(e) { const { id, type } = e.currentTarget.dataset - const favorites = [...this.data.favorites] + const favorites = this.data.favorites.slice() const index = favorites.indexOf(id) if (index > -1) { diff --git a/miniprogram/pages/plan/plan.js b/miniprogram/pages/plan/plan.js index b233d81..4497ba1 100644 --- a/miniprogram/pages/plan/plan.js +++ b/miniprogram/pages/plan/plan.js @@ -218,7 +218,7 @@ Page({ // 切换日期选择 toggleDateSelect(e) { const date = e.currentTarget.dataset.date - const selectedDates = [...this.data.selectedDates] + const selectedDates = this.data.selectedDates.slice() const index = selectedDates.indexOf(date) if (index > -1) { @@ -230,7 +230,7 @@ Page({ // 更新计划项的选中状态 const plans = this.data.plans.map(plan => { if (plan.date === date) { - return { ...plan, selected: !plan.selected } + return Object.assign({}, plan, { selected: !plan.selected }) } return plan }) @@ -262,7 +262,7 @@ Page({ } // 计算日期范围(最小和最大日期) - const sortedDates = [...this.data.selectedDates].sort() + const sortedDates = this.data.selectedDates.slice().sort() const startDate = sortedDates[0] const endDate = sortedDates[sortedDates.length - 1] diff --git a/miniprogram/project.private.config.json b/miniprogram/project.private.config.json index ed5702f..32c9a09 100644 --- a/miniprogram/project.private.config.json +++ b/miniprogram/project.private.config.json @@ -4,5 +4,5 @@ "setting": { "compileHotReLoad": true }, - "libVersion": "3.11.1" + "libVersion": "3.8.12" } \ No newline at end of file diff --git a/miniprogram/utils/imageManager.js b/miniprogram/utils/imageManager.js index 61f518a..c325bf0 100644 --- a/miniprogram/utils/imageManager.js +++ b/miniprogram/utils/imageManager.js @@ -70,8 +70,7 @@ class ImageManager { return [{ image_url: defaultImage, id: 1 }] } - return images.map((image, index) => ({ - ...image, + return images.map((image, index) => Object.assign({}, image, { image_url: this.processImageUrl(image, defaultImage), id: image.id || index + 1 })) @@ -85,7 +84,7 @@ class ImageManager { processUserAvatar(user) { if (!user) return user - const processedUser = { ...user } + const processedUser = Object.assign({}, user) if (user.avatar_url) { processedUser.avatar_url = this.processImageUrl(user.avatar_url, '/images/default-avatar.png') @@ -105,8 +104,7 @@ class ImageManager { processCookingSteps(steps) { if (!Array.isArray(steps)) return [] - return steps.map(step => ({ - ...step, + return steps.map(step => Object.assign({}, step, { image_url: step.image_url || step.image ? this.processImageUrl(step, '/images/default-dish.png') : null })) diff --git a/miniprogram/utils/request.js b/miniprogram/utils/request.js index 06a2c5c..c38efa2 100644 --- a/miniprogram/utils/request.js +++ b/miniprogram/utils/request.js @@ -23,10 +23,9 @@ function request(options) { method, data, timeout: 60000, // 增加到60秒超时 - header: { - 'Content-Type': 'application/json', - ...header - }, + header: Object.assign({ + 'Content-Type': 'application/json' + }, header), success(res) { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(res.data) @@ -91,7 +90,7 @@ function request(options) { (1000 * (retry + 1)) // 1秒、2秒、3秒 setTimeout(() => { - request({ ...options, retry: retry + 1 }) + request(Object.assign({}, options, { retry: retry + 1 })) .then(resolve) .catch(reject) }, delay) diff --git a/miniprogram/utils/util.js b/miniprogram/utils/util.js index 9fe7f97..57d8de8 100644 --- a/miniprogram/utils/util.js +++ b/miniprogram/utils/util.js @@ -113,7 +113,8 @@ function showConfirm(content, title = '提示') { */ function debounce(fn, delay = 500) { let timer = null - return function(...args) { + return function() { + const args = Array.prototype.slice.call(arguments) if (timer) clearTimeout(timer) timer = setTimeout(() => { fn.apply(this, args) @@ -126,7 +127,8 @@ function debounce(fn, delay = 500) { */ function throttle(fn, delay = 500) { let lastTime = 0 - return function(...args) { + return function() { + const args = Array.prototype.slice.call(arguments) const now = Date.now() if (now - lastTime >= delay) { fn.apply(this, args) diff --git a/miniprogram/utils/version.js b/miniprogram/utils/version.js index 37bc528..e560bec 100644 --- a/miniprogram/utils/version.js +++ b/miniprogram/utils/version.js @@ -11,11 +11,10 @@ const VERSION_INFO = { // 获取版本信息 function getVersionInfo() { - return { - ...VERSION_INFO, + return Object.assign({}, VERSION_INFO, { // 完整版本号(包含版本名称) fullVersion: `${VERSION_INFO.version} (${VERSION_INFO.versionName})` - } + }) } // 导出版本信息 -- Gitee From 955f016192f6cfd11bc2f320ff40c9b7928f141c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Tue, 18 Nov 2025 13:21:45 +0800 Subject: [PATCH 38/44] =?UTF-8?q?1.=20=E5=9B=BE=E7=89=87=20hettpS=20?= =?UTF-8?q?=E9=97=AE=E9=A2=98=202.=20=E6=97=8B=E8=BD=AC=E5=90=8E=E8=A3=81?= =?UTF-8?q?=E5=88=87=E4=BF=AE=E5=A4=8D=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/pages/chef/dish-edit/dish-edit.js | 4 ++++ miniprogram/pages/image-edit/image-edit.js | 18 ++++++++++++++++-- miniprogram/utils/imageManager.js | 15 ++++++++++++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.js b/miniprogram/pages/chef/dish-edit/dish-edit.js index 9dde61a..c4a96a5 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.js +++ b/miniprogram/pages/chef/dish-edit/dish-edit.js @@ -411,6 +411,8 @@ Page({ handleStepImageEdited(editedImagePath, stepIndex) { const steps = this.data.formData.cooking_steps if (stepIndex !== undefined && steps[stepIndex]) { + // 注意:http://tmp/ 是微信小程序内部的临时文件路径,必须保持原样 + // 虽然会有 HTTPS 警告,但这是微信内部的路径格式,功能可以正常工作 steps[stepIndex].tempPath = editedImagePath steps[stepIndex].image_url = null steps[stepIndex].image = null @@ -534,6 +536,8 @@ Page({ * 处理菜品图片编辑后的回调 */ handleDishImageEdited(editedImagePath) { + // 注意:http://tmp/ 是微信小程序内部的临时文件路径,必须保持原样 + // 虽然会有 HTTPS 警告,但这是微信内部的路径格式,功能可以正常工作 const images = this.data.formData.images images.push({ tempPath: editedImagePath, diff --git a/miniprogram/pages/image-edit/image-edit.js b/miniprogram/pages/image-edit/image-edit.js index 682f66d..393825f 100644 --- a/miniprogram/pages/image-edit/image-edit.js +++ b/miniprogram/pages/image-edit/image-edit.js @@ -37,6 +37,8 @@ Page({ if (options.src) { const src = decodeURIComponent(options.src) + // 注意:http://tmp/ 是微信小程序内部的临时文件路径,必须保持原样 + // 虽然会有 HTTPS 警告,但这是微信内部的路径格式,功能可以正常工作 const type = options.type || 'dish' // 默认为菜品图片 const stepIndex = options.stepIndex !== undefined ? parseInt(options.stepIndex) : null @@ -783,6 +785,7 @@ Page({ // 确保裁剪区域在图片范围内 sourceX = Math.max(0, Math.min(sourceX, originalWidth - sourceWidth)) sourceY = Math.max(0, Math.min(sourceY, originalHeight - sourceHeight)) + // 加载图片并绘制 const img = canvas.createImage() img.onload = () => { @@ -793,15 +796,23 @@ Page({ sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, cropWidth, cropHeight ) + console.log('裁切旋转前:', JSON.stringify({ + 裁切起始位置: { sourceX, sourceY }, + 裁切尺寸: { sourceWidth, sourceHeight } + }, null, 2)) } else { ctx.save() ctx.translate(cropWidth / 2, cropHeight / 2) ctx.rotate(rotation * Math.PI / 180) ctx.drawImage( img, - sourceX, sourceY, sourceWidth, sourceHeight, - -cropWidth / 2, -cropHeight / 2, cropWidth, cropHeight + sourceY, sourceX, sourceHeight, sourceWidth, + -cropHeight / 2, -cropWidth / 2,cropHeight, cropWidth ) + console.log('裁切旋转后:', JSON.stringify({ + 裁切起始位置: { sourceX, sourceY }, + 裁切尺寸: { sourceWidth, sourceHeight } + }, null, 2)) ctx.restore() } @@ -879,6 +890,9 @@ Page({ returnEditedImage(imagePath) { hideLoading() + // 注意:http://tmp/ 是微信小程序内部的临时文件路径,必须保持原样 + // 虽然会有 HTTPS 警告,但这是微信内部的路径格式,功能可以正常工作 + // 返回处理后的图片路径 const pages = getCurrentPages() const prevPage = pages[pages.length - 2] diff --git a/miniprogram/utils/imageManager.js b/miniprogram/utils/imageManager.js index c325bf0..d0826b4 100644 --- a/miniprogram/utils/imageManager.js +++ b/miniprogram/utils/imageManager.js @@ -39,7 +39,15 @@ class ImageManager { return defaultImage } - // 如果已经是本地路径(wxfile或/images),直接返回 + // 处理 http://tmp/ 临时文件路径(微信小程序内部临时文件) + // 注意:必须保持 http://tmp/ 原样,不能转换为 https://tmp/ + // 虽然会有 HTTPS 警告,但这是微信内部的路径格式,功能可以正常工作 + // 如果转换为 https://tmp/,微信会将其当作网络请求处理,导致失败 + if (imageUrl.startsWith('http://tmp/')) { + return imageUrl + } + + // 如果已经是本地路径(wxfile 或 /images),直接返回 if (imageUrl.startsWith('wxfile://') || imageUrl.startsWith('/images/')) { return imageUrl } @@ -117,8 +125,9 @@ class ImageManager { * @returns {Promise} 本地图片路径 */ async preloadImage(url, useCache = true) { - // 如果已经是本地路径,直接返回 - if (url.startsWith('wxfile://') || url.startsWith('/images/')) { + // 如果已经是本地路径(包括 http://tmp/ 临时文件),直接返回 + // 注意:http://tmp/ 是微信小程序内部的临时文件路径,必须保持原样 + if (url.startsWith('wxfile://') || url.startsWith('/images/') || url.startsWith('http://tmp/')) { return url } -- Gitee From abefce4ea8bd9071cc7830ab3c455b64d7e6aaac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Tue, 18 Nov 2025 13:53:31 +0800 Subject: [PATCH 39/44] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=8B=E8=BD=AC?= =?UTF-8?q?=E5=90=8E=E5=9B=BE=E7=89=87=E5=B7=A6=E5=8F=B3=E6=89=98=E5=8F=8D?= =?UTF-8?q?=E4=BA=86=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/pages/image-edit/image-edit.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/miniprogram/pages/image-edit/image-edit.js b/miniprogram/pages/image-edit/image-edit.js index 393825f..7d0eb20 100644 --- a/miniprogram/pages/image-edit/image-edit.js +++ b/miniprogram/pages/image-edit/image-edit.js @@ -735,14 +735,14 @@ Page({ // 将屏幕坐标系的偏移转换为原始图片坐标系的偏移 let offsetX, offsetY if (rotation === 90) { - offsetX = screenOffsetY - offsetY = -screenOffsetX + offsetX = -screenOffsetX + offsetY = screenOffsetY } else if (rotation === 180) { offsetX = -screenOffsetX offsetY = -screenOffsetY } else if (rotation === 270) { - offsetX = -screenOffsetY - offsetY = screenOffsetX + offsetX = screenOffsetX + offsetY = -screenOffsetY } else { offsetX = screenOffsetX offsetY = screenOffsetY @@ -758,6 +758,10 @@ Page({ displayToOriginalRatioY = originalHeight / imageHeight } if (displayToOriginalRatioX !== displayToOriginalRatioY) { + console.log('缩放比例不一致,无法裁切', JSON.stringify({ + displayToOriginalRatioX, + displayToOriginalRatioY + }, null, 2)) console.error('缩放比例不一致,无法裁切') } const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatioX -- Gitee From e374981141bf0917c24e99a9ee8e7f155d31fef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Tue, 18 Nov 2025 14:04:56 +0800 Subject: [PATCH 40/44] =?UTF-8?q?=E8=8F=9C=E5=93=81=E5=92=8C=E6=AD=A5?= =?UTF-8?q?=E9=AA=A4=E9=83=BD=E6=AD=A3=E5=B8=B8=20=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/pages/image-edit/image-edit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/miniprogram/pages/image-edit/image-edit.js b/miniprogram/pages/image-edit/image-edit.js index 7d0eb20..cc2dbf1 100644 --- a/miniprogram/pages/image-edit/image-edit.js +++ b/miniprogram/pages/image-edit/image-edit.js @@ -99,7 +99,7 @@ Page({ if (this.data.type === 'step') { this.calculateStepImageLayout(imageInfo) } else { - this.calculateDishImageLayout(imageInfo) + this.calculateStepImageLayout(imageInfo) } }, @@ -556,7 +556,7 @@ Page({ if (this.data.type === 'step') { this.confirmEditStep() } else { - this.confirmEditDish() + this.confirmEditStep() } }, -- Gitee From 811b3115ede18b00a3121a3d9c7e408d17507273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Tue, 18 Nov 2025 14:25:42 +0800 Subject: [PATCH 41/44] =?UTF-8?q?=E5=9B=BE=E7=89=87=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=BB=A3=E7=A0=81=E7=B2=BE=E7=AE=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/pages/image-edit/image-edit.js | 270 --------------------- 1 file changed, 270 deletions(-) diff --git a/miniprogram/pages/image-edit/image-edit.js b/miniprogram/pages/image-edit/image-edit.js index cc2dbf1..b6f0a98 100644 --- a/miniprogram/pages/image-edit/image-edit.js +++ b/miniprogram/pages/image-edit/image-edit.js @@ -91,117 +91,10 @@ Page({ } }) }, - /** * 计算图片布局 */ calculateImageLayout(imageInfo) { - if (this.data.type === 'step') { - this.calculateStepImageLayout(imageInfo) - } else { - this.calculateStepImageLayout(imageInfo) - } - }, - - /** - * 计算菜品图片布局(正方形750x750) - */ - calculateDishImageLayout(imageInfo) { - const { width, height } = imageInfo - const { cropWidth, cropHeight } = this.data - - // 获取屏幕信息 - const windowInfo = wx.getWindowInfo() - const screenWidth = windowInfo.windowWidth - const screenHeight = windowInfo.windowHeight - - // 图片的宽高比是固定的,旋转只是CSS变换,不影响布局计算 - // 始终使用原始图片的宽高比 - const imageRatio = width / height - - // 计算裁剪框在屏幕上的显示尺寸(保持比例) - const cropRatio = cropWidth / cropHeight - let cropDisplayWidth, cropDisplayHeight - if (screenWidth / screenHeight > cropRatio) { - // 屏幕更宽,以高度为基准 - cropDisplayHeight = screenHeight * 0.7 // 占用70%的屏幕高度 - cropDisplayWidth = cropDisplayHeight * cropRatio - } else { - // 屏幕更高,以宽度为基准 - cropDisplayWidth = screenWidth * 0.9 // 占用90%的屏幕宽度 - cropDisplayHeight = cropDisplayWidth / cropRatio - } - - // 确保裁剪框不会太大 - if (cropDisplayWidth > screenWidth * 0.9) { - cropDisplayWidth = screenWidth * 0.9 - cropDisplayHeight = cropDisplayWidth / cropRatio - } - if (cropDisplayHeight > screenHeight * 0.7) { - cropDisplayHeight = screenHeight * 0.7 - cropDisplayWidth = cropDisplayHeight * cropRatio - } - - // 计算图片的显示尺寸 - // 需求:短边与裁剪框一致(不限高度还是宽度) - // 比较图片和裁剪框的宽高比,决定以哪一边为基准 - let imageDisplayWidth, imageDisplayHeight - if (imageRatio > cropRatio) { - // 图片更宽(横向),高度与裁剪框高度一致 - imageDisplayHeight = cropDisplayHeight - imageDisplayWidth = imageDisplayHeight * imageRatio - } else { - // 图片更高(纵向)或等比例,宽度与裁剪框宽度一致 - imageDisplayWidth = cropDisplayWidth - imageDisplayHeight = imageDisplayWidth / imageRatio - } - - // 计算裁剪框在屏幕上的位置(居中) - const cropTop = (screenHeight - cropDisplayHeight) / 2 - const cropLeft = (screenWidth - cropDisplayWidth) / 2 - - // 计算图片位置(居中显示) - const imageLeft = (screenWidth - imageDisplayWidth) / 2 - const imageTop = (screenHeight - imageDisplayHeight) / 2 - - // 计算最小缩放比例 - // 确保图片缩放后能够完全覆盖裁剪框 - // 需要同时考虑宽度和高度方向 - const scaleX = cropDisplayWidth / imageDisplayWidth - const scaleY = cropDisplayHeight / imageDisplayHeight - const minScale = Math.max(scaleX, scaleY, 1.0) // 至少为1.0,确保能覆盖裁剪框 - const finalMinScale = minScale - - this.setData({ - originalWidth: width, - originalHeight: height, - imageWidth: imageDisplayWidth, - imageHeight: imageDisplayHeight, - imageTop: imageTop, - imageLeft: imageLeft, - cropDisplayWidth: cropDisplayWidth, - cropDisplayHeight: cropDisplayHeight, - cropTop: cropTop, - cropLeft: cropLeft, - scale: 1, - minScale: finalMinScale, - maxScale: 3, - translateX: 0, - translateY: 0 - }) - - console.log('菜品图片布局计算完成:', JSON.stringify({ - 原始尺寸: { width, height }, - 显示尺寸: { imageDisplayWidth, imageDisplayHeight }, - 裁剪框显示尺寸: { cropDisplayWidth, cropDisplayHeight }, - 屏幕尺寸: { screenWidth, screenHeight } - }, null, 2)) - }, - - /** - * 计算步骤图片布局(矩形1280x960,4:3比例) - */ - calculateStepImageLayout(imageInfo) { let { width, height } = imageInfo const { cropWidth, cropHeight } = this.data if (this.data.rotation === 90 || this.data.rotation === 270) { @@ -249,20 +142,16 @@ Page({ // 判断图片宽高和裁剪框宽高的比例 const widthRatio = width / cropDisplayWidth; const heightRatio = height / cropDisplayHeight; - console.log('width', width, 'height', height, 'cropWidth', cropWidth, 'cropHeight', cropHeight); - console.log('widthRatio', widthRatio, 'heightRatio', heightRatio); if (widthRatio > 1 && heightRatio > 1) { // 图片比裁剪框大,哪个比例小哪个贴边 if (widthRatio < heightRatio) { // 宽度贴边 imageDisplayWidth = cropDisplayWidth; imageDisplayHeight = imageDisplayWidth / imageRatio; - console.log('宽度贴边', '宽度', imageDisplayWidth, '高度', imageDisplayHeight); } else { // 高度贴边 imageDisplayHeight = cropDisplayHeight; imageDisplayWidth = imageDisplayHeight * imageRatio; - console.log('高度贴边', '宽度', imageDisplayWidth, '高度', imageDisplayHeight); } } else if (widthRatio > 1 || heightRatio > 1) { // 至少有一个 > 1,取大的那边贴边(另一个超出裁剪框) @@ -270,24 +159,20 @@ Page({ // 宽大于裁剪框,宽度贴边 imageDisplayWidth = cropDisplayWidth; imageDisplayHeight = imageDisplayWidth / imageRatio; - console.log('宽度贴边,高度小于裁切框', '宽度', imageDisplayWidth, '高度', imageDisplayHeight); } else { // 高大于裁剪框,高度贴边 imageDisplayHeight = cropDisplayHeight; imageDisplayWidth = imageDisplayHeight * imageRatio; - console.log('高度贴边,宽度小于裁切框', '宽度', imageDisplayWidth, '高度', imageDisplayHeight); } } else { // 两边都 <= 1,原图显示,不缩放 imageDisplayWidth = cropDisplayWidth < width ? cropDisplayWidth : width; imageDisplayHeight = cropDisplayHeight < height ? cropDisplayHeight : height; - console.log('原图显示', '宽度', imageDisplayWidth, '高度', imageDisplayHeight); } if (this.data.rotation === 90 || this.data.rotation === 270) { const temp = imageDisplayWidth; imageDisplayWidth = imageDisplayHeight; imageDisplayHeight = temp; - console.log('旋转后显示尺寸', '宽度', imageDisplayWidth, '高度', imageDisplayHeight); } // 计算裁剪框在屏幕上的位置(居中) const cropTop = (screenHeight - cropDisplayHeight) / 2 @@ -303,7 +188,6 @@ Page({ const scaleX = cropDisplayWidth / imageDisplayWidth const scaleY = cropDisplayHeight / imageDisplayHeight const minScale = Math.max(scaleX, scaleY, 1.0) // 至少为1.0,确保能覆盖裁剪框 - console.log('最小缩放比例', minScale); this.setData({ originalWidth: width, originalHeight: height, @@ -321,7 +205,6 @@ Page({ translateX: 0, translateY: 0 }) - console.log('步骤图片布局计算完成:', JSON.stringify({ 原始尺寸: { width, height }, 显示尺寸: { imageDisplayWidth, imageDisplayHeight }, @@ -492,16 +375,6 @@ Page({ maxY: centerOffsetY + maxOffsetY } - // 调试日志(仅在开发环境) - if (maxOffsetX === 0 && maxOffsetY === 0 && visualWidth > 0 && visualHeight > 0) { - console.log('拖动边界计算:', { - visualSize: { visualWidth, visualHeight }, - cropSize: { cropDisplayWidth, cropDisplayHeight }, - maxOffset: { maxOffsetX, maxOffsetY }, - bounds - }) - } - return bounds }, @@ -548,145 +421,10 @@ Page({ } wx.navigateBack() }, - /** * 确认编辑 */ confirmEdit() { - if (this.data.type === 'step') { - this.confirmEditStep() - } else { - this.confirmEditStep() - } - }, - - /** - * 确认编辑 - 菜品图片(正方形750x750) - */ - confirmEditDish() { - console.log('开始处理菜品图片') - showLoading('处理中...') - - const { src, originalWidth, originalHeight, imageWidth, imageHeight, imageTop, imageLeft, - cropWidth, cropHeight, cropDisplayWidth, cropDisplayHeight, cropTop, cropLeft, - scale, translateX, translateY, rotation } = this.data - - // 获取屏幕尺寸(用于计算旋转后的坐标转换) - const windowInfo = wx.getWindowInfo() - const deviceInfo = wx.getDeviceInfo() - const screenWidth = windowInfo.windowWidth - const screenHeight = windowInfo.windowHeight - const dpr = deviceInfo.pixelRatio || 1 - - // 使用 Canvas 2D API 获取画布上下文 - // 注意:可能会在控制台看到 SharedArrayBuffer 的弃用警告,这是浏览器/开发者工具的警告, - // 不影响微信小程序的实际功能,可以安全忽略 - wx.createSelectorQuery() - .select('#finalCanvas') - .fields({ node: true, size: true }) - .exec((res) => { - const canvas = res[0].node - const ctx = canvas.getContext('2d') - - // 设置 canvas 实际像素尺寸 - const canvasWidth = cropWidth - const canvasHeight = cropHeight - canvas.width = canvasWidth * dpr - canvas.height = canvasHeight * dpr - - // 缩放上下文以匹配设备像素比 - ctx.scale(dpr, dpr) - - // 计算图片中心点(在屏幕上的位置) - const imageCenterX = imageLeft + imageWidth / 2 - const imageCenterY = imageTop + imageHeight / 2 - - // 计算裁剪框中心点(在屏幕上的位置) - const cropCenterX = cropLeft + cropDisplayWidth / 2 - const cropCenterY = cropTop + cropDisplayHeight / 2 - - // 将裁剪框中心点相对于图片中心点的偏移(在屏幕坐标系下) - let screenOffsetX = cropCenterX - imageCenterX - translateX - let screenOffsetY = cropCenterY - imageCenterY - translateY - - // 将屏幕坐标系的偏移转换为原始图片坐标系的偏移 - let offsetX, offsetY - if (rotation === 90) { - offsetX = screenOffsetY - offsetY = -screenOffsetX - } else if (rotation === 180) { - offsetX = -screenOffsetX - offsetY = -screenOffsetY - } else if (rotation === 270) { - offsetX = -screenOffsetY - offsetY = screenOffsetX - } else { - offsetX = screenOffsetX - offsetY = screenOffsetY - } - - const displayToOriginalRatioX = originalHeight / imageWidth - const displayToOriginalRatioY = originalWidth / imageHeight - const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatioX - const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatioY - - // 计算裁剪区域的尺寸(菜品图片是正方形) - const ratioX = originalWidth / imageWidth - const ratioY = originalHeight / imageHeight - const sourceSizeX = (cropDisplayWidth / scale) * ratioX - const sourceSizeY = (cropDisplayHeight / scale) * ratioY - - // 取较小值,确保裁剪区域是正方形且不超出图片范围 - const sourceSize = Math.min(sourceSizeX, sourceSizeY) - const sourceWidth = sourceSize - const sourceHeight = sourceSize - - // 计算裁剪区域的起始位置 - let sourceX = originalWidth / 2 + offsetInOriginalX - sourceWidth / 2 - let sourceY = originalHeight / 2 + offsetInOriginalY - sourceHeight / 2 - - // 确保裁剪区域在图片范围内 - sourceX = Math.max(0, Math.min(sourceX, originalWidth - sourceWidth)) - sourceY = Math.max(0, Math.min(sourceY, originalHeight - sourceHeight)) - - // 加载图片并绘制 - const img = canvas.createImage() - img.onload = () => { - // 绘制裁剪后的图片 - if (rotation === 0) { - ctx.drawImage( - img, - sourceX, sourceY, sourceWidth, sourceHeight, - 0, 0, cropWidth, cropHeight - ) - } else { - ctx.save() - ctx.translate(cropWidth / 2, cropHeight / 2) - ctx.rotate(rotation * Math.PI / 180) - ctx.drawImage( - img, - sourceX, sourceY, sourceWidth, sourceHeight, - -cropWidth / 2, -cropHeight / 2, cropWidth, cropHeight - ) - ctx.restore() - } - - // Canvas 2D API 不需要调用 draw,直接导出 - this.exportCanvas(canvas) - } - img.onerror = (err) => { - console.error('图片加载失败:', err) - hideLoading() - showError('图片加载失败') - } - img.src = src - }) - }, - - /** - * 确认编辑 - 步骤图片(矩形1280x960,4:3比例) - */ - confirmEditStep() { console.log('开始处理步骤图片') showLoading('处理中...') @@ -800,10 +538,6 @@ Page({ sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, cropWidth, cropHeight ) - console.log('裁切旋转前:', JSON.stringify({ - 裁切起始位置: { sourceX, sourceY }, - 裁切尺寸: { sourceWidth, sourceHeight } - }, null, 2)) } else { ctx.save() ctx.translate(cropWidth / 2, cropHeight / 2) @@ -813,10 +547,6 @@ Page({ sourceY, sourceX, sourceHeight, sourceWidth, -cropHeight / 2, -cropWidth / 2,cropHeight, cropWidth ) - console.log('裁切旋转后:', JSON.stringify({ - 裁切起始位置: { sourceX, sourceY }, - 裁切尺寸: { sourceWidth, sourceHeight } - }, null, 2)) ctx.restore() } -- Gitee From 141965cbf1477beceba94a918d7c4de6b05d80d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Tue, 18 Nov 2025 14:30:00 +0800 Subject: [PATCH 42/44] =?UTF-8?q?=E8=8F=9C=E5=93=81=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E9=A1=B5=E3=80=82=E6=AD=A5=E9=AA=A4=E5=9B=BE=E7=89=87=E7=9A=84?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E3=80=82=E6=98=AF=E6=AD=A3=E6=96=B9=E5=BD=A2?= =?UTF-8?q?=E7=9A=84=EF=BC=8C=E6=B2=A1=E6=9C=89=E6=98=BE=E7=A4=BA=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E4=BF=A1=E6=81=AF=E3=80=82=E3=80=82=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=8C=89=E7=85=A7=E6=AD=A5=E9=AA=A4=E5=9B=BE=E7=89=87=E5=BA=94?= =?UTF-8?q?=E8=AF=A5=E6=9C=89=E7=9A=84=E6=AF=94=E4=BE=8B=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/pages/chef/dish-edit/dish-edit.wxml | 2 +- miniprogram/pages/chef/dish-edit/dish-edit.wxss | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.wxml b/miniprogram/pages/chef/dish-edit/dish-edit.wxml index 01bcd60..a7cb165 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.wxml +++ b/miniprogram/pages/chef/dish-edit/dish-edit.wxml @@ -105,7 +105,7 @@ × diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.wxss b/miniprogram/pages/chef/dish-edit/dish-edit.wxss index ec2241a..372e99b 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.wxss +++ b/miniprogram/pages/chef/dish-edit/dish-edit.wxss @@ -292,12 +292,10 @@ .step-image-item { position: relative; width: 200rpx; - height: 200rpx; } .step-preview-image { width: 100%; - height: 100%; border-radius: 8rpx; } -- Gitee From fc9f74e9abc44a6097dc9f126347d9711b1176ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Tue, 18 Nov 2025 14:41:52 +0800 Subject: [PATCH 43/44] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=8F=9C=E5=93=81?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E9=A1=B5=E9=9D=A2=E7=9A=84+=20X=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/chef/dish-edit/dish-edit.wxml | 24 +++++-- .../pages/chef/dish-edit/dish-edit.wxss | 71 +++++++++++++++---- 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.wxml b/miniprogram/pages/chef/dish-edit/dish-edit.wxml index a7cb165..487bb01 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.wxml +++ b/miniprogram/pages/chef/dish-edit/dish-edit.wxml @@ -44,10 +44,12 @@ bindtap="previewDishImage" data-index="{{index}}" data-url="{{item.image_url || item.tempPath}}"> - × + + × + - + + + @@ -71,7 +73,9 @@ - × + + × + {{validationErrors.ingredients}} @@ -93,9 +97,13 @@ - × + + × + + + + × - × @@ -108,10 +116,12 @@ mode="widthFix" bindtap="previewStepImage" data-url="{{step.image_url || step.tempPath}}"> - × + + × + - + + + diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.wxss b/miniprogram/pages/chef/dish-edit/dish-edit.wxss index 372e99b..07359bd 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.wxss +++ b/miniprogram/pages/chef/dish-edit/dish-edit.wxss @@ -37,12 +37,19 @@ right: -10rpx; width: 40rpx; height: 40rpx; - line-height: 40rpx; - text-align: center; + display: flex; + align-items: center; + justify-content: center; background-color: #ff4d4f; color: #fff; border-radius: 50%; +} + +.remove-icon { font-size: 32rpx; + line-height: 1; + margin: 0; + padding: 0; } .add-image-btn { @@ -53,10 +60,16 @@ display: flex; align-items: center; justify-content: center; - font-size: 60rpx; color: #d9d9d9; } +.add-image-icon { + font-size: 60rpx; + line-height: 1; + margin: 0; + padding: 0; +} + .form-label-row { display: flex; justify-content: space-between; @@ -131,12 +144,19 @@ .remove-ingredient-btn { width: 40rpx; height: 40rpx; - line-height: 40rpx; - text-align: center; + display: flex; + align-items: center; + justify-content: center; background-color: #ff4d4f; color: #fff; border-radius: 50%; +} + +.remove-ingredient-btn .remove-icon { font-size: 28rpx; + line-height: 1; + margin: 0; + padding: 0; } /* 底部操作栏 */ @@ -254,23 +274,37 @@ .remove-step-btn { width: 50rpx; height: 50rpx; - line-height: 50rpx; - text-align: center; + display: flex; + align-items: center; + justify-content: center; background: #ff4d4f; color: #fff; border-radius: 50%; +} + +.remove-step-btn .remove-icon { font-size: 32rpx; + line-height: 1; + margin: 0; + padding: 0; } .remove-step-btn-alone { width: 50rpx; height: 50rpx; - line-height: 50rpx; - text-align: center; + display: flex; + align-items: center; + justify-content: center; background: #ff4d4f; color: #fff; border-radius: 50%; +} + +.remove-step-btn-alone .remove-icon { font-size: 32rpx; + line-height: 1; + margin: 0; + padding: 0; } .step-description { @@ -305,12 +339,19 @@ right: -10rpx; width: 40rpx; height: 40rpx; - line-height: 40rpx; - text-align: center; + display: flex; + align-items: center; + justify-content: center; background-color: #ff4d4f; color: #fff; border-radius: 50%; +} + +.step-image-remove-btn .remove-icon { font-size: 32rpx; + line-height: 1; + margin: 0; + padding: 0; } .step-add-image-btn { @@ -321,10 +362,16 @@ display: flex; align-items: center; justify-content: center; - font-size: 60rpx; color: #d9d9d9; } +.step-add-image-btn .add-image-icon { + font-size: 60rpx; + line-height: 1; + margin: 0; + padding: 0; +} + /* 单位选择器样式 */ .ingredient-input-wrapper { flex: 1; -- Gitee From 7c24819e69e18831b9d3c8470c00963e909c2405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=80=E6=9E=97?= <447083059@qq.com> Date: Tue, 18 Nov 2025 16:28:59 +0800 Subject: [PATCH 44/44] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=86=85=E5=AE=B9=201.?= =?UTF-8?q?=20=E8=8F=9C=E5=8D=95=E9=A1=B5=E9=9D=A2=EF=BC=88miniprogram/pag?= =?UTF-8?q?es/menu/=EF=BC=89=20=E6=B7=BB=E5=8A=A0=20dish-image-wrapper=20?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=EF=BC=8C=E4=BD=BF=E7=94=A8=20padding-bottom:?= =?UTF-8?q?=20100%=20=E5=AE=9E=E7=8E=B0=E6=AD=A3=E6=96=B9=E5=BD=A2?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=20=E5=B0=86=E5=9B=BE=E7=89=87=20mode=20?= =?UTF-8?q?=E4=BB=8E=20aspectFill=20=E6=94=B9=E4=B8=BA=20aspectFit?= =?UTF-8?q?=EF=BC=8C=E7=A1=AE=E4=BF=9D=E5=9B=BE=E7=89=87=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=20=E5=9B=BE=E7=89=87=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E7=BB=9D=E5=AF=B9=E5=AE=9A=E4=BD=8D=E5=A1=AB=E5=85=85=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=202.=20=E8=8F=9C=E5=93=81=E8=AF=A6=E6=83=85=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=EF=BC=88miniprogram/pages/gourmet/dish-detail/?= =?UTF-8?q?=EF=BC=89=20=E5=B0=86=E8=8F=9C=E5=93=81=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=9A=84=20mode=20=E4=BB=8E=20aspectFill=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=20aspectFit=EF=BC=8C=E7=A1=AE=E4=BF=9D=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=98=BE=E7=A4=BA=20=E5=B0=86=E6=AD=A5?= =?UTF-8?q?=E9=AA=A4=E5=9B=BE=E7=89=87=E7=9A=84=20mode=20=E4=BB=8E=20aspec?= =?UTF-8?q?tFill=20=E6=94=B9=E4=B8=BA=20aspectFit=EF=BC=8C=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E5=9B=BE=E7=89=87=E5=AE=8C=E6=95=B4=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=20=E7=A7=BB=E9=99=A4=E4=BA=86=E6=AD=A5=E9=AA=A4=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E7=9A=84=E5=9B=BA=E5=AE=9A=E9=AB=98=E5=BA=A6=E9=99=90?= =?UTF-8?q?=E5=88=B6=EF=BC=8C=E8=AE=A9=E5=AE=83=E8=83=BD=E5=A4=9F=E8=87=AA?= =?UTF-8?q?=E9=80=82=E5=BA=94=E9=AB=98=E5=BA=A6=20=E7=8E=B0=E5=9C=A8?= =?UTF-8?q?=EF=BC=9A=20=E8=8F=9C=E5=8D=95=E9=A1=B5=E9=9D=A2=E7=9A=84?= =?UTF-8?q?=E8=8F=9C=E5=93=81=E5=9B=BE=E7=89=87=E4=BB=A5=E6=AD=A3=E6=96=B9?= =?UTF-8?q?=E5=BD=A2=E6=98=BE=E7=A4=BA=EF=BC=8C=E4=B8=94=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=98=BE=E7=A4=BA=20=E8=8F=9C=E5=93=81?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2=E7=9A=84=E8=8F=9C=E5=93=81?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=92=8C=E6=AD=A5=E9=AA=A4=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E9=83=BD=E8=83=BD=E5=AE=8C=E6=95=B4=E6=98=BE=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E4=BC=9A=E8=A2=AB=E8=A3=81=E5=89=AA=20=E6=89=80?= =?UTF-8?q?=E6=9C=89=E4=BF=AE=E6=94=B9=E5=B7=B2=E5=AE=8C=E6=88=90=EF=BC=8C?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=20lint=20=E9=94=99=E8=AF=AF=E3=80=82?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E6=B5=8B=E8=AF=95=E6=9F=A5=E7=9C=8B=E6=95=88?= =?UTF-8?q?=E6=9E=9C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/gourmet/dish-detail/dish-detail.wxml | 4 ++-- .../pages/gourmet/dish-detail/dish-detail.wxss | 2 +- miniprogram/pages/menu/menu.wxml | 16 +++++++++------- miniprogram/pages/menu/menu.wxss | 13 ++++++++++++- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml index 9ba4cd8..b27b68c 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml @@ -13,7 +13,7 @@ diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxss b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxss index d6d2e4c..10b4386 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxss +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxss @@ -265,9 +265,9 @@ .step-img { width: 100%; - height: 300rpx; border-radius: 12rpx; background: #f0f0f0; + display: block; } /* 营养标签区域 */ diff --git a/miniprogram/pages/menu/menu.wxml b/miniprogram/pages/menu/menu.wxml index 99fb155..10daecd 100644 --- a/miniprogram/pages/menu/menu.wxml +++ b/miniprogram/pages/menu/menu.wxml @@ -61,13 +61,15 @@ - + + + {{item.name}} {{categories[item.dish_type] || categories[item.category] || '未分类'}} diff --git a/miniprogram/pages/menu/menu.wxss b/miniprogram/pages/menu/menu.wxss index 721f65e..dd21b2a 100644 --- a/miniprogram/pages/menu/menu.wxss +++ b/miniprogram/pages/menu/menu.wxss @@ -126,9 +126,20 @@ box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.05); } +.dish-image-wrapper { + width: 100%; + padding-bottom: 100%; /* 保持1:1的宽高比,实现正方形 */ + position: relative; + overflow: hidden; + background: #f5f5f5; +} + .dish-image { + position: absolute; + top: 0; + left: 0; width: 100%; - height: 200rpx; + height: 100%; } .dish-info { -- Gitee