v3.5.16小程序首页动画

This commit is contained in:
2026-08-30 15:07:58 +08:00
parent 9c8d5f2cad
commit b375fab44a
13 changed files with 546 additions and 759 deletions
+1 -1
View File
@@ -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 {
-2
View File
@@ -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);
+2 -2
View File
@@ -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);
@@ -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);
}
}
@@ -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 (
<View
className={`home-splash${leaving ? ' home-splash--out' : ''}`}
catchMove
onTouchMove={(e) => {
e.stopPropagation?.();
}}
>
<View className="home-splash-veil" />
<View className="home-splash-copy">
<Text className="home-splash-title"></Text>
<Text className="home-splash-sub"></Text>
</View>
<View className="home-splash-stage">
<View className="home-splash-glow" />
<Image
className="home-splash-bottle"
src={HOME_SPLASH_BOTTLE_URL}
mode="aspectFit"
style={{ width: '220px', height: '420px' }}
/>
<View
className="home-splash-lights"
style={{
width: '220px',
height: '420px',
WebkitMaskImage: `url(${HOME_SPLASH_BOTTLE_URL})`,
maskImage: `url(${HOME_SPLASH_BOTTLE_URL})`,
WebkitMaskSize: 'contain',
maskSize: 'contain',
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
}}
>
<View className="home-splash-beam" />
<View className="home-splash-sheen home-splash-sheen--a" />
<View className="home-splash-sheen home-splash-sheen--b" />
</View>
</View>
<View className="home-splash-skip" onClick={finish}>
<Text className="home-splash-skip-text"></Text>
</View>
</View>
);
}
@@ -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;
}
}
@@ -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 (
<View
className={`jiuzu-splash${gifDone ? ' jiuzu-splash--after-gif' : ''}${leaving ? ' jiuzu-splash--out' : ''}`}
catchMove
onTouchMove={(e) => {
e.stopPropagation?.();
}}
>
<View className="jiuzu-splash-mist jiuzu-splash-mist--a" />
<View className="jiuzu-splash-mist jiuzu-splash-mist--b" />
{gifGone ? null : (
<Image
className={`jiuzu-splash-gif${gifDone ? ' jiuzu-splash-gif--out' : ''}`}
src={JIUZU_SPLASH_GIF_URL}
mode="aspectFill"
style={{ width: '100%', height: '100%' }}
/>
)}
<View className="jiuzu-splash-copy">
<View className="jiuzu-splash-lockup">
<Image
className="jiuzu-splash-mark"
src={JIUZU_SPLASH_MARK_URL}
mode="aspectFit"
style={{ width: '320px', height: '180px' }}
/>
<View className="jiuzu-splash-title">
<View className="jiuzu-splash-chars">
<View className="jiuzu-splash-shimmer" />
{CHARS.map((ch) => (
<View key={ch} className="jiuzu-splash-char">
<Text className="jiuzu-splash-char-text">{ch}</Text>
</View>
))}
</View>
</View>
</View>
<View className="jiuzu-splash-sub">
<Text className="jiuzu-splash-sub-text"> · </Text>
</View>
</View>
<View className="jiuzu-splash-skip" onClick={finish}>
<Text className="jiuzu-splash-skip-text"></Text>
</View>
</View>
);
}
+21
View File
@@ -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 开场图(仅 weappH5 的 getImageInfo 会走 CORS */
export function prefetchHomeSplashAssets() {
if (played) return;
if (process.env.TARO_ENV !== 'weapp') return;
void Taro.getImageInfo({ src: HOME_SPLASH_BOTTLE_URL }).catch(() => {});
}
@@ -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);
}
-22
View File
@@ -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 开场图(仅 weappH5 的 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(() => {});
}
+4 -4
View File
@@ -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<AromaKey>('QINGXIANG');
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
@@ -428,7 +428,7 @@ export default function HomePage() {
) : null}
{shouldRenderPageTabBar() && !showSplash ? <UserTabBar selected={0} /> : null}
{showSplash ? <JiuzuSplash onDone={() => setShowSplash(false)} /> : null}
{showSplash ? <HomeSplash onDone={() => setShowSplash(false)} /> : null}
</PageShell>
);
}
+3 -6
View File
@@ -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';
+122
View File
@@ -0,0 +1,122 @@
/**
* 把本地文件上传到 OSS `static/mini-user/`(不进小程序主包)。
* 凭证优先读 system_config,其次 server/dukang-api/.env。
*
* 用法:node scripts/upload-mini-user-static.mjs <localFile> [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 <localFile> [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}`);