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}`);