From b375fab44a0763efbb5439100887bdf76a9e2e0d Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 30 Aug 2026 15:07:58 +0800 Subject: [PATCH] =?UTF-8?q?v3.5.16=E5=B0=8F=E7=A8=8B=E5=BA=8F=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mini-user/src/app.css | 2 +- apps/mini-user/src/app.tsx | 2 - apps/mini-user/src/app.weapp.tsx | 4 +- apps/mini-user/src/components/HomeSplash.css | 272 +++++++++++++++ apps/mini-user/src/components/HomeSplash.tsx | 121 +++++++ apps/mini-user/src/components/JiuzuSplash.css | 266 --------------- apps/mini-user/src/components/JiuzuSplash.tsx | 140 -------- apps/mini-user/src/lib/home-splash.ts | 21 ++ apps/mini-user/src/lib/jiuzu-dragon-canvas.ts | 316 ------------------ apps/mini-user/src/lib/jiuzu-splash.ts | 22 -- apps/mini-user/src/pages/home/index.tsx | 8 +- packages/shared-types/src/config.ts | 9 +- scripts/upload-mini-user-static.mjs | 122 +++++++ 13 files changed, 546 insertions(+), 759 deletions(-) create mode 100644 apps/mini-user/src/components/HomeSplash.css create mode 100644 apps/mini-user/src/components/HomeSplash.tsx delete mode 100644 apps/mini-user/src/components/JiuzuSplash.css delete mode 100644 apps/mini-user/src/components/JiuzuSplash.tsx create mode 100644 apps/mini-user/src/lib/home-splash.ts delete mode 100644 apps/mini-user/src/lib/jiuzu-dragon-canvas.ts delete mode 100644 apps/mini-user/src/lib/jiuzu-splash.ts create mode 100644 scripts/upload-mini-user-static.mjs diff --git a/apps/mini-user/src/app.css b/apps/mini-user/src/app.css index c4108d9..42e0645 100644 --- a/apps/mini-user/src/app.css +++ b/apps/mini-user/src/app.css @@ -7,7 +7,7 @@ @import './styles/mine.css'; @import './styles/address.css'; @import './styles/benefit-promo.css'; -@import './components/JiuzuSplash.css'; +@import './components/HomeSplash.css'; page, body { diff --git a/apps/mini-user/src/app.tsx b/apps/mini-user/src/app.tsx index 11f7a5d..74ecf6e 100644 --- a/apps/mini-user/src/app.tsx +++ b/apps/mini-user/src/app.tsx @@ -6,14 +6,12 @@ import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks'; import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm'; import { installClientErrorReporting } from './lib/client-error'; import { prefetchShareBrandAssets } from './lib/wechat-share'; -import { prefetchJiuzuSplashAssets } from './lib/jiuzu-splash'; import './app.css'; // H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime patchTaroH5Hooks(); installClientErrorReporting(); prefetchShareBrandAssets(); -prefetchJiuzuSplashAssets(); function App({ children }: PropsWithChildren) { const handlingRef = useRef(false); diff --git a/apps/mini-user/src/app.weapp.tsx b/apps/mini-user/src/app.weapp.tsx index 85b5207..8edbeab 100644 --- a/apps/mini-user/src/app.weapp.tsx +++ b/apps/mini-user/src/app.weapp.tsx @@ -4,14 +4,14 @@ import Taro, { useDidShow } from '@tarojs/taro'; import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm'; import { installClientErrorReporting } from './lib/client-error'; import { prefetchShareBrandAssets } from './lib/wechat-share'; -import { prefetchJiuzuSplashAssets } from './lib/jiuzu-splash'; +import { prefetchHomeSplashAssets } from './lib/home-splash'; import { capturePromoSceneAndTouchScan } from './lib/promo'; import { initClientVersionChecks } from './lib/client-version'; import './app.css'; installClientErrorReporting(); prefetchShareBrandAssets(); -prefetchJiuzuSplashAssets(); +prefetchHomeSplashAssets(); function App({ children }: PropsWithChildren) { const handlingRef = useRef(false); diff --git a/apps/mini-user/src/components/HomeSplash.css b/apps/mini-user/src/components/HomeSplash.css new file mode 100644 index 0000000..4ac1aa4 --- /dev/null +++ b/apps/mini-user/src/components/HomeSplash.css @@ -0,0 +1,272 @@ +/* 首页开场:半透明底 + 酒瓶 + 分流光 + 文字渐显 */ + +.home-splash { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 10010; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: hidden; + pointer-events: auto; +} + +.home-splash--out { + animation: home-splash-out 0.8s ease-in forwards; + pointer-events: none; +} + +.home-splash-veil { + position: absolute; + z-index: 0; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: rgba(20, 8, 8, 0.58); + background-image: radial-gradient( + ellipse at 50% 48%, + rgba(166, 29, 36, 0.42) 0%, + rgba(20, 8, 8, 0.62) 62%, + rgba(10, 4, 4, 0.72) 100% + ); + opacity: 0; + animation: home-splash-fade 0.7s ease-out forwards; +} + +.home-splash-copy { + position: relative; + z-index: 3; + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 8px; +} + +.home-splash-title { + font-family: 'Songti SC', 'STSong', 'Noto Serif SC', 'PingFang SC', serif; + font-size: 40px; + font-weight: 700; + line-height: 1.2; + letter-spacing: 0.22em; + color: #f5d76e; + text-shadow: 0 0 14px rgba(255, 191, 0, 0.55), 0 2px 10px rgba(20, 8, 8, 0.45); + opacity: 0; + animation: home-splash-fade 1.1s 0.65s ease-out forwards; +} + +.home-splash-sub { + margin-top: 10px; + font-size: 13px; + letter-spacing: 0.16em; + color: rgba(255, 248, 210, 0.88); + opacity: 0; + animation: home-splash-fade 1.1s 1.35s ease-out forwards; +} + +.home-splash-stage { + position: relative; + z-index: 2; + width: 220px; + height: 420px; + overflow: hidden; + opacity: 0; + animation: home-splash-fade 1s 0.2s ease-out forwards; +} + +.home-splash-glow { + position: absolute; + z-index: 0; + left: 18px; + right: 18px; + top: 18%; + bottom: 8%; + border-radius: 50%; + background: radial-gradient(ellipse at 50% 50%, rgba(255, 191, 0, 0.34) 0%, rgba(255, 191, 0, 0) 72%); + opacity: 0; + animation: home-splash-glow 2.6s 0.8s ease-in-out infinite; +} + +.home-splash-beam { + position: absolute; + z-index: 1; + top: 0; + bottom: 0; + left: 50%; + width: 46px; + margin-left: -23px; + background: linear-gradient( + 180deg, + rgba(255, 248, 210, 0) 0%, + rgba(255, 248, 210, 0.16) 28%, + rgba(255, 191, 0, 0.22) 50%, + rgba(255, 248, 210, 0.1) 78%, + rgba(255, 248, 210, 0) 100% + ); + opacity: 0; + animation: home-splash-beam 3.2s 1.1s ease-in-out infinite; +} + +.home-splash-bottle { + position: relative; + z-index: 2; + display: block; + width: 220px; + height: 420px; +} + +.home-splash-bottle img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.home-splash-lights { + position: absolute; + z-index: 3; + top: 0; + left: 0; + width: 220px; + height: 420px; + overflow: hidden; + pointer-events: none; + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-mode: alpha; +} + +.home-splash-sheen { + position: absolute; + z-index: 3; + top: -12%; + bottom: -12%; + pointer-events: none; + opacity: 0; +} + +.home-splash-sheen--a { + width: 48px; + left: 0; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 248, 210, 0.08) 28%, + rgba(255, 248, 210, 0.55) 50%, + rgba(255, 191, 0, 0.18) 72%, + rgba(255, 255, 255, 0) 100% + ); + transform: translateX(-70px) skewX(-22deg); + animation: home-splash-sheen-a 2.8s 1.4s ease-in-out infinite; +} + +.home-splash-sheen--b { + width: 22px; + left: 0; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.42) 50%, + rgba(255, 255, 255, 0) 100% + ); + transform: translateX(-50px) skewX(-18deg); + animation: home-splash-sheen-b 3.6s 2.3s ease-in-out infinite; +} + +.home-splash-skip { + position: absolute; + top: 16px; + right: 16px; + z-index: 4; + padding: 6px 14px; + border-radius: 999px; + border: 1px solid rgba(245, 215, 110, 0.45); + background: rgba(20, 8, 8, 0.35); + opacity: 0; + animation: home-splash-fade 0.6s 0.35s ease-out forwards; +} + +.home-splash-skip-text { + color: rgba(255, 248, 210, 0.92); + font-size: 12px; + line-height: 1.4; + letter-spacing: 0.08em; +} + +@keyframes home-splash-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes home-splash-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes home-splash-glow { + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 0.85; + } +} + +@keyframes home-splash-beam { + 0%, + 100% { + opacity: 0; + } + 40% { + opacity: 0.7; + } + 70% { + opacity: 0.15; + } +} + +@keyframes home-splash-sheen-a { + 0% { + opacity: 0; + transform: translateX(-70px) skewX(-22deg); + } + 18% { + opacity: 1; + } + 82% { + opacity: 0.7; + } + 100% { + opacity: 0; + transform: translateX(250px) skewX(-22deg); + } +} + +@keyframes home-splash-sheen-b { + 0% { + opacity: 0; + transform: translateX(-40px) skewX(-18deg); + } + 22% { + opacity: 0.9; + } + 100% { + opacity: 0; + transform: translateX(240px) skewX(-18deg); + } +} diff --git a/apps/mini-user/src/components/HomeSplash.tsx b/apps/mini-user/src/components/HomeSplash.tsx new file mode 100644 index 0000000..62ba211 --- /dev/null +++ b/apps/mini-user/src/components/HomeSplash.tsx @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Image, Text, View } from '@tarojs/components'; +import Taro from '@tarojs/taro'; +import { HOME_SPLASH_BOTTLE_URL } from '@dukang/shared-types'; +import { markHomeSplashPlayed } from '../lib/home-splash'; + +const SPLASH_NAV_BG = '#140808'; +const HOME_NAV_BG = '#FAF9F7'; +const FADE_AT_MS = 4800; +const FADE_MS = 800; + +type HomeSplashProps = { + onDone: () => void; +}; + +function applySplashChrome() { + try { + void Taro.hideTabBar({ animation: false }); + } catch { + /* H5 无原生 TabBar */ + } + void Taro.setNavigationBarColor({ + frontColor: '#ffffff', + backgroundColor: SPLASH_NAV_BG, + animation: { duration: 200, timingFunc: 'easeIn' }, + }).catch(() => {}); +} + +function restoreChrome() { + try { + void Taro.showTabBar({ animation: false }); + } catch { + /* H5 无原生 TabBar */ + } + void Taro.setNavigationBarColor({ + frontColor: '#000000', + backgroundColor: HOME_NAV_BG, + animation: { duration: 200, timingFunc: 'easeOut' }, + }).catch(() => {}); +} + +export default function HomeSplash({ onDone }: HomeSplashProps) { + const finishedRef = useRef(false); + const fadingRef = useRef(false); + const onDoneRef = useRef(onDone); + onDoneRef.current = onDone; + const [leaving, setLeaving] = useState(false); + + const finish = useCallback(() => { + if (finishedRef.current) return; + finishedRef.current = true; + restoreChrome(); + onDoneRef.current(); + }, []); + + const beginExit = useCallback(() => { + if (fadingRef.current || finishedRef.current) return; + fadingRef.current = true; + setLeaving(true); + setTimeout(finish, FADE_MS); + }, [finish]); + + useEffect(() => { + markHomeSplashPlayed(); + applySplashChrome(); + const timer = setTimeout(beginExit, FADE_AT_MS); + return () => { + clearTimeout(timer); + if (!finishedRef.current) restoreChrome(); + }; + }, [beginExit]); + + return ( + { + e.stopPropagation?.(); + }} + > + + + + 杜康好客 + 购杜康好酒,赠好客权益 + + + + + + + + + + + + + + 跳过 + + + ); +} diff --git a/apps/mini-user/src/components/JiuzuSplash.css b/apps/mini-user/src/components/JiuzuSplash.css deleted file mode 100644 index 5c065ba..0000000 --- a/apps/mini-user/src/components/JiuzuSplash.css +++ /dev/null @@ -1,266 +0,0 @@ -/* 酒祖杜康开场:黑红底 → GIF 播完消失 → 四字上移 → 副标题跟上 */ - -.jiuzu-splash { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 10010; - overflow: hidden; - background-color: #140808; - background-image: radial-gradient(ellipse at 50% 42%, #5a1014 0%, #2a080a 48%, #140808 100%); - animation: jiuzu-bg-in 0.35s ease-out both; -} - -.jiuzu-splash--out { - animation: jiuzu-bg-out 0.8s ease-in forwards; - pointer-events: none; -} - -.jiuzu-splash-mist { - position: absolute; - z-index: 0; - border-radius: 50%; - pointer-events: none; - filter: blur(48px); -} - -.jiuzu-splash-mist--a { - width: 280px; - height: 280px; - left: -72px; - top: 12%; - background: rgba(166, 29, 36, 0.38); -} - -.jiuzu-splash-mist--b { - width: 240px; - height: 240px; - right: -56px; - bottom: 16%; - background: rgba(90, 16, 20, 0.5); -} - -.jiuzu-splash-gif { - position: absolute; - z-index: 5; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: block; - width: 100%; - height: 100%; - pointer-events: none; -} - -.jiuzu-splash-gif--out { - animation: jiuzu-bg-out 0.35s ease-in forwards; -} - -.jiuzu-splash-gif img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.jiuzu-splash-copy { - position: absolute; - z-index: 2; - left: 0; - right: 0; - top: 26%; - display: flex; - flex-direction: column; - align-items: center; -} - -.jiuzu-splash-lockup { - position: relative; - width: 320px; - height: 180px; - display: flex; - align-items: center; - justify-content: center; -} - -.jiuzu-splash-title { - position: relative; - z-index: 2; - opacity: 0; - transform: translateY(48vh); -} - -.jiuzu-splash--after-gif .jiuzu-splash-title { - animation: jiuzu-title-rise 2.4s cubic-bezier(0.22, 1, 0.32, 1) forwards; -} - -.jiuzu-splash-mark { - position: absolute; - z-index: 0; - top: 0; - left: 0; - width: 320px; - height: 180px; - opacity: 0; - pointer-events: none; -} - -.jiuzu-splash--after-gif .jiuzu-splash-mark { - animation: fadeInTo4 1s ease-out both; -} - -.jiuzu-splash-mark img { - width: 100%; - height: 100%; - object-fit: contain; -} - -.jiuzu-splash-chars { - position: relative; - z-index: 2; - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - overflow: hidden; - padding: 8px 4px; -} - -.jiuzu-splash-char { - width: 52px; -} - -.jiuzu-splash-char-text { - display: block; - width: 100%; - font-family: 'Songti SC', 'STSong', 'Noto Serif SC', 'PingFang SC', serif; - font-size: 46px; - font-weight: 700; - line-height: 1.15; - text-align: center; - color: #f5d76e; - text-shadow: 0 0 12px rgba(255, 191, 0, 0.85), 0 0 28px rgba(20, 8, 8, 0.65); -} - -.jiuzu-splash-shimmer { - position: absolute; - top: -10%; - bottom: -10%; - width: 36px; - z-index: 3; - pointer-events: none; - opacity: 0; - background: linear-gradient( - 90deg, - rgba(255, 255, 255, 0) 0%, - rgba(255, 248, 210, 0.55) 50%, - rgba(255, 255, 255, 0) 100% - ); - transform: translateX(-80px) skewX(-18deg); -} - -.jiuzu-splash--after-gif .jiuzu-splash-shimmer { - animation: jiuzu-shimmer 0.7s 2.2s ease-out both; -} - -.jiuzu-splash-sub { - position: relative; - z-index: 2; - margin-top: 4px; - opacity: 0; - transform: translateY(36vh); -} - -.jiuzu-splash--after-gif .jiuzu-splash-sub { - animation: jiuzu-sub-rise 0.4s 1.3s cubic-bezier(0.22, 1, 0.32, 1) forwards; -} - -.jiuzu-splash-sub-text { - font-size: 13px; - letter-spacing: 0.28em; - color: rgba(245, 215, 110, 0.88); -} - -.jiuzu-splash-skip { - position: absolute; - top: 16px; - right: 16px; - z-index: 6; - padding: 6px 14px; - border-radius: 999px; - border: 1px solid rgba(245, 215, 110, 0.45); - background: rgba(20, 8, 8, 0.35); -} - -.jiuzu-splash-skip-text { - color: rgba(255, 248, 210, 0.92); - font-size: 12px; - line-height: 1.4; - letter-spacing: 0.08em; -} - -@keyframes fadeInTo4 { - 0% { opacity: 0; } - 100% { opacity: 0.4; } -} - -@keyframes jiuzu-bg-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes jiuzu-bg-out { - from { - opacity: 1; - } - to { - opacity: 0; - } -} - -@keyframes jiuzu-title-rise { - 0% { - opacity: 0; - transform: translateY(48vh); - } - 14% { - opacity: 1; - transform: translateY(48vh); - } - 100% { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes jiuzu-sub-rise { - 0% { - opacity: 0; - transform: translateY(36vh); - } - 18% { - opacity: 1; - transform: translateY(36vh); - } - 100% { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes jiuzu-shimmer { - from { - transform: translateX(-80px) skewX(-18deg); - opacity: 0.2; - } - to { - transform: translateX(280px) skewX(-18deg); - opacity: 0; - } -} diff --git a/apps/mini-user/src/components/JiuzuSplash.tsx b/apps/mini-user/src/components/JiuzuSplash.tsx deleted file mode 100644 index 8d151f3..0000000 --- a/apps/mini-user/src/components/JiuzuSplash.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Image, Text, View } from '@tarojs/components'; -import Taro from '@tarojs/taro'; -import { JIUZU_SPLASH_GIF_URL, JIUZU_SPLASH_MARK_URL } from '@dukang/shared-types'; -import { markJiuzuSplashPlayed } from '../lib/jiuzu-splash'; - -const CHARS = ['酒', '祖', '杜', '康'] as const; - -const SPLASH_NAV_BG = '#140808'; -const HOME_NAV_BG = '#FAF9F7'; -/** GIF 21 帧 × 80ms,略提前淡出避免循环 */ -const GIF_MS = 1650; -const GIF_FADE_MS = 350; -/** 四字显现并升到偏上位置 */ -const TITLE_MS = 2400; -/** 副标题在四字到位后再升起 */ -const SUB_DELAY_MS = 2300; -const SUB_MS = 1400; -const HOLD_MS = 900; -const FADE_MS = 800; -const FADE_AT_MS = GIF_MS + SUB_DELAY_MS + SUB_MS + HOLD_MS; - -type JiuzuSplashProps = { - onDone: () => void; -}; - -function applySplashChrome() { - try { - void Taro.hideTabBar({ animation: false }); - } catch { - /* H5 无原生 TabBar */ - } - void Taro.setNavigationBarColor({ - frontColor: '#ffffff', - backgroundColor: SPLASH_NAV_BG, - animation: { duration: 200, timingFunc: 'easeIn' }, - }).catch(() => {}); -} - -function restoreChrome() { - try { - void Taro.showTabBar({ animation: false }); - } catch { - /* H5 无原生 TabBar */ - } - void Taro.setNavigationBarColor({ - frontColor: '#000000', - backgroundColor: HOME_NAV_BG, - animation: { duration: 200, timingFunc: 'easeOut' }, - }).catch(() => {}); -} - -export default function JiuzuSplash({ onDone }: JiuzuSplashProps) { - const finishedRef = useRef(false); - const fadingRef = useRef(false); - const onDoneRef = useRef(onDone); - onDoneRef.current = onDone; - const [leaving, setLeaving] = useState(false); - const [gifDone, setGifDone] = useState(false); - const [gifGone, setGifGone] = useState(false); - - const finish = useCallback(() => { - if (finishedRef.current) return; - finishedRef.current = true; - restoreChrome(); - onDoneRef.current(); - }, []); - - const beginExit = useCallback(() => { - if (fadingRef.current || finishedRef.current) return; - fadingRef.current = true; - setLeaving(true); - setTimeout(finish, FADE_MS); - }, [finish]); - - useEffect(() => { - markJiuzuSplashPlayed(); - applySplashChrome(); - const gifTimer = setTimeout(() => setGifDone(true), GIF_MS); - const gifGoneTimer = setTimeout(() => setGifGone(true), GIF_MS + GIF_FADE_MS); - const exitTimer = setTimeout(beginExit, FADE_AT_MS); - return () => { - clearTimeout(gifTimer); - clearTimeout(gifGoneTimer); - clearTimeout(exitTimer); - if (!finishedRef.current) restoreChrome(); - }; - }, [beginExit]); - - return ( - { - e.stopPropagation?.(); - }} - > - - - - {gifGone ? null : ( - - )} - - - - - - - - {CHARS.map((ch) => ( - - {ch} - - ))} - - - - - - 千年酒祖 · 杜康好客 - - - - - 跳过 - - - ); -} diff --git a/apps/mini-user/src/lib/home-splash.ts b/apps/mini-user/src/lib/home-splash.ts new file mode 100644 index 0000000..f1621e4 --- /dev/null +++ b/apps/mini-user/src/lib/home-splash.ts @@ -0,0 +1,21 @@ +import Taro from '@tarojs/taro'; +import { HOME_SPLASH_BOTTLE_URL } from '@dukang/shared-types'; + +/** 冷启动会话内是否已播过首页开场(进程级,切 Tab 不重播) */ + +let played = false; + +export function hasHomeSplashPlayed() { + return played; +} + +export function markHomeSplashPlayed() { + played = true; +} + +/** 冷启动预拉 OSS 开场图(仅 weapp;H5 的 getImageInfo 会走 CORS) */ +export function prefetchHomeSplashAssets() { + if (played) return; + if (process.env.TARO_ENV !== 'weapp') return; + void Taro.getImageInfo({ src: HOME_SPLASH_BOTTLE_URL }).catch(() => {}); +} diff --git a/apps/mini-user/src/lib/jiuzu-dragon-canvas.ts b/apps/mini-user/src/lib/jiuzu-dragon-canvas.ts deleted file mode 100644 index dd873e6..0000000 --- a/apps/mini-user/src/lib/jiuzu-dragon-canvas.ts +++ /dev/null @@ -1,316 +0,0 @@ -/** Canvas 金龙:盘成一圈,仿照立体金龙的鳞片、须、角、爪与光晕 */ - -export type DragonCanvasNode = { - width: number; - height: number; - getContext: (type: '2d') => CanvasRenderingContext2D; - requestAnimationFrame?: (cb: (time: number) => void) => number; - cancelAnimationFrame?: (id: number) => void; -}; - -type SpinePt = { - x: number; - y: number; - ang: number; - nx: number; - ny: number; - w: number; -}; - -const GOLD_HI = '#fff6c8'; -const GOLD = '#ffbf00'; -const GOLD_MID = '#e8a800'; -const GOLD_DEEP = '#b87500'; - -function lerp(a: number, b: number, t: number) { - return a + (b - a) * t; -} - -function fillOval( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - rw: number, - rh: number, - rot: number, -) { - ctx.save(); - ctx.translate(x, y); - ctx.rotate(rot); - ctx.scale(Math.max(0.01, rw), Math.max(0.01, rh)); - ctx.beginPath(); - ctx.arc(0, 0, 1, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); -} - -function easeInCubic(t: number) { - return t * t * t; -} - -function buildSpine(cx: number, cy: number, r: number, phase: number, segs: number): SpinePt[] { - const pts: SpinePt[] = []; - const turns = 0.94; - for (let i = 0; i < segs; i++) { - const u = i / (segs - 1); - const ang = -Math.PI / 2 + u * Math.PI * 2 * turns; - const wobble = Math.sin(u * 14 + phase) * r * 0.042 + Math.sin(u * 5.5 - phase * 0.7) * r * 0.02; - const rr = r + wobble; - const nx = Math.cos(ang); - const ny = Math.sin(ang); - pts.push({ - x: cx + nx * rr, - y: cy + ny * rr, - ang, - nx, - ny, - w: lerp(20, 6.5, u ** 0.62), - }); - } - return pts; -} - -function strokeRibbon( - ctx: CanvasRenderingContext2D, - pts: SpinePt[], - widthScale: number, - color: string, - alpha: number, -) { - if (pts.length < 2) return; - ctx.save(); - ctx.globalAlpha = alpha; - ctx.strokeStyle = color; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.beginPath(); - ctx.moveTo(pts[0].x, pts[0].y); - for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); - ctx.lineWidth = pts[Math.floor(pts.length * 0.15)].w * widthScale; - ctx.stroke(); - ctx.restore(); -} - -function drawScales(ctx: CanvasRenderingContext2D, pts: SpinePt[]) { - for (let i = 2; i < pts.length - 1; i += 1) { - const p = pts[i]; - const u = i / (pts.length - 1); - const ox = p.x + p.nx * p.w * 0.18; - const oy = p.y + p.ny * p.w * 0.18; - ctx.save(); - ctx.translate(ox, oy); - ctx.rotate(p.ang + Math.PI / 2); - ctx.fillStyle = i % 2 === 0 ? GOLD_HI : GOLD; - ctx.globalAlpha = 0.55 + (1 - u) * 0.25; - fillOval(ctx, 0, 0, p.w * 0.55, p.w * 0.38, 0); - ctx.restore(); - } -} - -function drawSpines(ctx: CanvasRenderingContext2D, pts: SpinePt[]) { - ctx.fillStyle = GOLD_HI; - for (let i = 3; i < pts.length - 6; i += 3) { - const p = pts[i]; - const len = p.w * 1.35; - ctx.save(); - ctx.globalAlpha = 0.85; - ctx.beginPath(); - ctx.moveTo(p.x + p.nx * p.w * 0.2, p.y + p.ny * p.w * 0.2); - ctx.lineTo( - p.x + p.nx * (p.w + len), - p.y + p.ny * (p.w + len), - ); - const tx = -p.ny; - const ty = p.nx; - ctx.lineTo(p.x + tx * 2.2, p.y + ty * 2.2); - ctx.closePath(); - ctx.fill(); - ctx.restore(); - } -} - -function drawClaw(ctx: CanvasRenderingContext2D, p: SpinePt, side: number) { - const tx = -p.ny * side; - const ty = p.nx * side; - const baseX = p.x + tx * p.w * 0.7; - const baseY = p.y + ty * p.w * 0.7; - ctx.save(); - ctx.translate(baseX, baseY); - ctx.rotate(Math.atan2(ty, tx)); - ctx.fillStyle = GOLD; - ctx.strokeStyle = GOLD_DEEP; - ctx.lineWidth = 0.8; - for (let k = -1; k <= 1; k++) { - ctx.beginPath(); - ctx.moveTo(0, k * 4); - ctx.quadraticCurveTo(10, k * 6 - 2, 18, k * 5); - ctx.quadraticCurveTo(10, k * 4, 0, k * 3); - ctx.closePath(); - ctx.fill(); - ctx.stroke(); - } - ctx.restore(); -} - -function drawHead(ctx: CanvasRenderingContext2D, p: SpinePt, phase: number) { - ctx.save(); - ctx.translate(p.x + p.nx * 10, p.y + p.ny * 10); - ctx.rotate(Math.atan2(p.ny, p.nx) + Math.PI / 2); - - const mane = 6; - for (let i = 0; i < mane; i++) { - const a = -0.9 + (i / (mane - 1)) * 1.8; - ctx.beginPath(); - ctx.strokeStyle = i % 2 ? GOLD_HI : GOLD; - ctx.globalAlpha = 0.7; - ctx.lineWidth = 2.2; - ctx.moveTo(Math.sin(a) * 6, -4); - ctx.quadraticCurveTo(Math.sin(a) * 16, -18 - Math.sin(phase + i) * 3, Math.sin(a) * 8, -28); - ctx.stroke(); - } - ctx.globalAlpha = 1; - - ctx.beginPath(); - ctx.moveTo(-7, -18); - ctx.quadraticCurveTo(-16, -32, -5, -38); - ctx.quadraticCurveTo(-2, -26, -3, -16); - ctx.fillStyle = GOLD_MID; - ctx.fill(); - ctx.beginPath(); - ctx.moveTo(7, -18); - ctx.quadraticCurveTo(16, -32, 5, -38); - ctx.quadraticCurveTo(2, -26, 3, -16); - ctx.fill(); - - const g = ctx.createRadialGradient(-4, -4, 2, 0, 4, 20); - g.addColorStop(0, GOLD_HI); - g.addColorStop(0.45, GOLD); - g.addColorStop(1, GOLD_DEEP); - ctx.fillStyle = g; - fillOval(ctx, 0, 2, 16, 18, 0); - - ctx.fillStyle = GOLD_MID; - fillOval(ctx, 0, 10, 9, 8, 0); - - for (const sx of [-6.5, 6.5]) { - ctx.fillStyle = '#3a1a00'; - fillOval(ctx, sx, -2, 3.2, 3.6, 0); - ctx.fillStyle = '#ffe566'; - fillOval(ctx, sx, -2.4, 1.5, 1.7, 0); - ctx.fillStyle = '#fff'; - fillOval(ctx, sx - 0.5, -3, 0.6, 0.6, 0); - } - - ctx.strokeStyle = GOLD_HI; - ctx.lineWidth = 1.15; - ctx.globalAlpha = 0.9; - for (const side of [-1, 1]) { - ctx.beginPath(); - ctx.moveTo(side * 12, 6); - ctx.quadraticCurveTo(side * 36, 10 + Math.sin(phase) * 2, side * 42, 22); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(side * 10, 9); - ctx.quadraticCurveTo(side * 28, 18, side * 34, 28); - ctx.stroke(); - } - ctx.globalAlpha = 1; - ctx.restore(); -} - -function drawSparks( - ctx: CanvasRenderingContext2D, - cx: number, - cy: number, - r: number, - phase: number, -) { - for (let i = 0; i < 28; i++) { - const a = (i / 28) * Math.PI * 2 + phase * 0.35; - const rr = r * (0.72 + ((i * 17) % 10) / 40); - const x = cx + Math.cos(a) * rr + Math.sin(phase * 1.4 + i) * 4; - const y = cy + Math.sin(a) * rr + Math.cos(phase * 1.1 + i) * 3; - const s = 1.1 + (i % 5) * 0.35; - ctx.beginPath(); - ctx.globalAlpha = 0.25 + (Math.sin(phase * 2 + i) + 1) * 0.25; - ctx.fillStyle = i % 3 === 0 ? GOLD_HI : GOLD; - ctx.arc(x, y, s, 0, Math.PI * 2); - ctx.fill(); - } - ctx.globalAlpha = 1; -} - -export function drawJiuzuDragonFrame( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - elapsedMs: number, -) { - const cx = width / 2; - const cy = height * 0.42; - const radius = Math.min(width, height) * 0.3; - - const fadeIn = Math.min(1, elapsedMs / 380); - const spinT = Math.min(1, Math.max(0, (elapsedMs - 120) / 2050)); - const flyT = Math.min(1, Math.max(0, (elapsedMs - 2200) / 1200)); - const spin = spinT * Math.PI * 2; - const fly = easeInCubic(flyT); - const phase = elapsedMs / 220; - - ctx.clearRect(0, 0, width, height); - ctx.save(); - ctx.globalAlpha = fadeIn * (1 - fly); - ctx.translate(cx, cy + fly * -height * 0.42); - ctx.scale(1 + fly * 0.55, 1 + fly * 0.55); - ctx.rotate(spin); - ctx.translate(-cx, -cy); - - const pts = buildSpine(cx, cy, radius, phase, 56); - strokeRibbon(ctx, pts, 2.4, 'rgba(255, 191, 0, 0.18)', 1); - strokeRibbon(ctx, pts, 1.55, 'rgba(255, 214, 80, 0.4)', 1); - - ctx.save(); - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.beginPath(); - ctx.moveTo(pts[0].x, pts[0].y); - for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); - const bodyGrad = ctx.createLinearGradient(cx - radius, cy, cx + radius, cy); - bodyGrad.addColorStop(0, GOLD_DEEP); - bodyGrad.addColorStop(0.5, GOLD); - bodyGrad.addColorStop(1, GOLD_HI); - ctx.strokeStyle = bodyGrad; - ctx.lineWidth = pts[0].w * 1.15; - ctx.shadowColor = 'rgba(255, 191, 0, 0.7)'; - ctx.shadowBlur = 16; - ctx.stroke(); - ctx.shadowBlur = 0; - ctx.restore(); - - drawScales(ctx, pts); - drawSpines(ctx, pts); - drawClaw(ctx, pts[Math.floor(pts.length * 0.32)], 1); - drawClaw(ctx, pts[Math.floor(pts.length * 0.68)], -1); - drawHead(ctx, pts[0], phase); - drawSparks(ctx, cx, cy, radius, phase); - - ctx.restore(); -} - -export function scheduleDragonFrame( - canvas: DragonCanvasNode, - cb: (time: number) => void, -): number { - if (typeof canvas.requestAnimationFrame === 'function') { - return canvas.requestAnimationFrame(cb); - } - return requestAnimationFrame(cb); -} - -export function cancelDragonFrame(canvas: DragonCanvasNode, id: number) { - if (typeof canvas.cancelAnimationFrame === 'function') { - canvas.cancelAnimationFrame(id); - return; - } - cancelAnimationFrame(id); -} diff --git a/apps/mini-user/src/lib/jiuzu-splash.ts b/apps/mini-user/src/lib/jiuzu-splash.ts deleted file mode 100644 index 3233c2a..0000000 --- a/apps/mini-user/src/lib/jiuzu-splash.ts +++ /dev/null @@ -1,22 +0,0 @@ -import Taro from '@tarojs/taro'; -import { JIUZU_SPLASH_GIF_URL, JIUZU_SPLASH_MARK_URL } from '@dukang/shared-types'; - -/** 冷启动会话内是否已播过「酒祖杜康」开场(进程级,切 Tab 不重播) */ - -let played = false; - -export function hasJiuzuSplashPlayed() { - return played; -} - -export function markJiuzuSplashPlayed() { - played = true; -} - -/** 冷启动预拉 OSS 开场图(仅 weapp;H5 的 getImageInfo 会走 CORS) */ -export function prefetchJiuzuSplashAssets() { - if (played) return; - if (process.env.TARO_ENV !== 'weapp') return; - void Taro.getImageInfo({ src: JIUZU_SPLASH_GIF_URL }).catch(() => {}); - void Taro.getImageInfo({ src: JIUZU_SPLASH_MARK_URL }).catch(() => {}); -} \ No newline at end of file diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx index a7d4c80..093dde6 100644 --- a/apps/mini-user/src/pages/home/index.tsx +++ b/apps/mini-user/src/pages/home/index.tsx @@ -13,9 +13,9 @@ import BenefitSloganBar from '../../components/BenefitSloganBar'; import { BENEFIT_GIFT_TAG, BENEFIT_TAG } from '../../lib/benefit-copy'; import WechatShareReady from '../../components/WechatShareReady'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; -import JiuzuSplash from '../../components/JiuzuSplash'; +import HomeSplash from '../../components/HomeSplash'; import { goLogin } from '../../lib/auth-nav'; -import { hasJiuzuSplashPlayed } from '../../lib/jiuzu-splash'; +import { hasHomeSplashPlayed } from '../../lib/home-splash'; import { getToken, isLoggedIn, request, toast } from '../../lib/api'; import { getHomeCatalogCache, @@ -83,7 +83,7 @@ function formatBenefitCorner(p: Product): string { } export default function HomePage() { - const [showSplash, setShowSplash] = useState(() => !hasJiuzuSplashPlayed()); + const [showSplash, setShowSplash] = useState(() => !hasHomeSplashPlayed()); const [activeAroma, setActiveAroma] = useState('QINGXIANG'); const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); @@ -428,7 +428,7 @@ export default function HomePage() { ) : null} {shouldRenderPageTabBar() && !showSplash ? : null} - {showSplash ? setShowSplash(false)} /> : null} + {showSplash ? setShowSplash(false)} /> : null} ); } diff --git a/packages/shared-types/src/config.ts b/packages/shared-types/src/config.ts index 00582b1..9a63514 100644 --- a/packages/shared-types/src/config.ts +++ b/packages/shared-types/src/config.ts @@ -54,16 +54,13 @@ export const BRAND_LOGO_MARK_URL = `${BRAND_LOGO_OSS_BASE}logo2.png`; /** 小程序静态资源根路径(默认;系统设置 MINI_USER_STATIC_OSS_BASE 可覆盖) */ export const MINI_USER_STATIC_OSS_BASE = - 'https://dukang-dev.oss-cn-beijing.aliyuncs.com/static/mini-user/'; + 'https://dukang-prod.oss-cn-hangzhou.aliyuncs.com/static/mini-user/'; /** 「我的」页资质公示长图(默认;系统设置 QUALIFICATION_DISCLOSURE_URL 可覆盖) */ export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualification-disclosure.png`; -/** 小程序开场金龙 GIF(冷启动;不进主包,走 OSS) */ -export const JIUZU_SPLASH_GIF_URL = `${MINI_USER_STATIC_OSS_BASE}jiuzu-dragon.gif`; - -/** 小程序开场酒祖印记图(冷启动;不进主包,走 OSS) */ -export const JIUZU_SPLASH_MARK_URL = `${MINI_USER_STATIC_OSS_BASE}jiuzu-dragon-mark.png`; +/** 首页开场酒瓶图(不进主包,走 OSS) */ +export const HOME_SPLASH_BOTTLE_URL = `${MINI_USER_STATIC_OSS_BASE}home-splash-bottle.png`; /** 总部客服电话(默认;系统设置 CUSTOMER_SERVICE_PHONE 可覆盖) */ export const CUSTOMER_SERVICE_PHONE = '13203801799'; diff --git a/scripts/upload-mini-user-static.mjs b/scripts/upload-mini-user-static.mjs new file mode 100644 index 0000000..e8756a2 --- /dev/null +++ b/scripts/upload-mini-user-static.mjs @@ -0,0 +1,122 @@ +/** + * 把本地文件上传到 OSS `static/mini-user/`(不进小程序主包)。 + * 凭证优先读 system_config,其次 server/dukang-api/.env。 + * + * 用法:node scripts/upload-mini-user-static.mjs [objectName] + */ +import { readFileSync, existsSync } from 'node:fs'; +import { basename, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); +const apiRoot = resolve(root, 'server/dukang-api'); +const require = createRequire(resolve(apiRoot, 'package.json')); +const OSS = require('ali-oss'); +const { PrismaClient } = require('@prisma/client'); + +function loadEnvFile(path) { + if (!existsSync(path)) return; + for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) process.env[key] = value; + } +} + +loadEnvFile(resolve(apiRoot, '.env')); +loadEnvFile(resolve(apiRoot, '.env.local')); + +const localFile = process.argv[2]; +if (!localFile) { + console.error('用法:node scripts/upload-mini-user-static.mjs [objectName]'); + process.exit(1); +} +const absFile = resolve(process.cwd(), localFile); +if (!existsSync(absFile)) { + console.error(`文件不存在:${absFile}`); + process.exit(1); +} +const objectName = process.argv[3] || basename(absFile); +const ossKey = `static/mini-user/${objectName.replace(/^\/+/, '')}`; + +const prisma = new PrismaClient(); +const rows = await prisma.systemConfig.findMany({ + where: { + configKey: { + in: [ + 'OSS_ACCESS_KEY_ID', + 'OSS_ACCESS_KEY_SECRET', + 'OSS_BUCKET', + 'OSS_REGION', + 'OSS_ENDPOINT', + 'OSS_CDN_BASE', + 'OSS_AUTHORIZATION_V4', + 'MINI_USER_STATIC_OSS_BASE', + ], + }, + }, +}); +await prisma.$disconnect(); + +for (const row of rows) { + if (row.value?.trim()) process.env[row.configKey] = row.value.trim(); +} + +const accessKeyId = process.env.OSS_ACCESS_KEY_ID ?? ''; +const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET ?? ''; +const bucket = process.env.OSS_BUCKET ?? ''; +const region = process.env.OSS_REGION ?? 'oss-cn-hangzhou'; +const endpoint = process.env.OSS_ENDPOINT ?? ''; +const cdnBase = (process.env.OSS_CDN_BASE ?? '').replace(/\/$/, ''); +const staticBase = (process.env.MINI_USER_STATIC_OSS_BASE ?? '').replace(/\/$/, '/'); + +if (!accessKeyId || !accessKeySecret || !bucket) { + console.error('OSS 未配置:请在 HQ 系统设置或 .env 填写 OSS_ACCESS_KEY_ID / SECRET / BUCKET'); + process.exit(1); +} + +const client = new OSS({ + region, + accessKeyId, + accessKeySecret, + bucket, + ...(endpoint ? { endpoint } : {}), + ...(process.env.OSS_AUTHORIZATION_V4 === 'true' ? { authorizationV4: true } : {}), +}); + +const mime = + absFile.endsWith('.png') + ? 'image/png' + : absFile.endsWith('.gif') + ? 'image/gif' + : absFile.endsWith('.jpg') || absFile.endsWith('.jpeg') + ? 'image/jpeg' + : 'application/octet-stream'; + +const result = await client.put(ossKey, absFile, { + mime, + headers: { + 'Content-Disposition': 'inline', + 'Cache-Control': 'public, max-age=31536000', + }, +}); + +const publicUrl = + (staticBase ? `${staticBase}${objectName}` : '') || + (cdnBase ? `${cdnBase}/${ossKey}` : result.url); + +console.log(`uploaded bucket=${bucket} region=${region}`); +console.log(`key=${ossKey}`); +console.log(`url=${publicUrl}`);