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 1/2] =?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}`); From e68eb4d38c2251b5f9c337a07cae228ede0990c0 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 30 Aug 2026 15:25:56 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(settlement):=20=E9=97=A8=E5=BA=97?= =?UTF-8?q?=E8=B4=A6=E5=8D=95=E7=A1=AE=E8=AE=A4=E6=89=93=E6=AC=BE=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=B8=8A=E4=BC=A0=E5=87=AD=E8=AF=81=E7=85=A7=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 财务确认打款与提现通过可附带 OSS 凭证图,详情与导出展示。 Co-authored-by: Cursor --- .../src/components/FinancePayProof.tsx | 97 ++++++++++++ apps/admin-web/src/pages/StoreBillsPage.tsx | 149 ++++++++++-------- .../src/pages/StoreWithdrawalsPage.tsx | 65 ++++---- docs/杜康好客-v3-PRD.md | 2 +- docs/杜康好客-v3-现状对照.md | 1 + docs/杜康好客-v3编码手册.md | 2 + packages/shared-types/src/settlement.ts | 11 ++ .../migrate-store-bill-payment-proof.sql | 5 + server/dukang-api/prisma/schema.prisma | 16 +- .../settlement/settlement.controller.ts | 13 +- .../modules/settlement/settlement.service.ts | 46 +++++- 11 files changed, 293 insertions(+), 114 deletions(-) create mode 100644 apps/admin-web/src/components/FinancePayProof.tsx create mode 100644 server/dukang-api/prisma/migrate-store-bill-payment-proof.sql diff --git a/apps/admin-web/src/components/FinancePayProof.tsx b/apps/admin-web/src/components/FinancePayProof.tsx new file mode 100644 index 0000000..89b2256 --- /dev/null +++ b/apps/admin-web/src/components/FinancePayProof.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { Image, Input, Modal, Space, Typography } from 'antd'; +import { PAYMENT_PROOF_IMAGE_MAX_COUNT } from '@dukang/shared-types'; +import MultiImageUpload from './MultiImageUpload'; + +export function parsePaymentProofUrls(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw.map((u) => String(u ?? '').trim()).filter(Boolean); +} + +export function PaymentProofGallery({ urls }: { urls?: unknown }) { + const list = parsePaymentProofUrls(urls); + if (!list.length) return <>—; + return ( + + + {list.map((url, index) => ( + + ))} + + + ); +} + +type FinancePayProofModalProps = { + open: boolean; + title: string; + hint: string; + okText: string; + confirmLoading?: boolean; + onCancel: () => void; + onOk: (payload: { paymentRef?: string; paymentProofUrls?: string[] }) => Promise; +}; + +export function FinancePayProofModal({ + open, + title, + hint, + okText, + confirmLoading, + onCancel, + onOk, +}: FinancePayProofModalProps) { + const [paymentRef, setPaymentRef] = useState(''); + const [proofUrls, setProofUrls] = useState([]); + + useEffect(() => { + if (open) { + setPaymentRef(''); + setProofUrls([]); + } + }, [open]); + + return ( + { + await onOk({ + paymentRef: paymentRef.trim() || undefined, + paymentProofUrls: proofUrls.length ? proofUrls : undefined, + }); + }} + > + {hint} + setPaymentRef(e.target.value)} + style={{ marginBottom: 12 }} + /> + 打款凭证照片(可选,银行转账回单等) +
+ +
+
+ ); +} diff --git a/apps/admin-web/src/pages/StoreBillsPage.tsx b/apps/admin-web/src/pages/StoreBillsPage.tsx index 11360b1..e27b449 100644 --- a/apps/admin-web/src/pages/StoreBillsPage.tsx +++ b/apps/admin-web/src/pages/StoreBillsPage.tsx @@ -18,6 +18,7 @@ import { Typography, message, } from 'antd'; +import { FinancePayProofModal, PaymentProofGallery } from '../components/FinancePayProof'; import type { ColumnsType } from 'antd/es/table'; import type { Dayjs } from 'dayjs'; import { @@ -113,6 +114,13 @@ export default function StoreBillsPage() { pendingCount: number; overdueCount: number; } | null>(null); + const [payModal, setPayModal] = useState<{ + ids: string[]; + amountHint?: number; + } | null>(null); + const [paySubmitting, setPaySubmitting] = useState(false); + const [withdrawApproveId, setWithdrawApproveId] = useState(null); + const [withdrawSubmitting, setWithdrawSubmitting] = useState(false); useEffect(() => { form.setFieldsValue({ @@ -134,77 +142,11 @@ export default function StoreBillsPage() { }, []); function confirmPay(ids: string[], amountHint?: number) { - let paymentRef = ''; - Modal.confirm({ - title: '确认打款?', - content: ( -
-
- 将确认 {ids.length} 笔 T+1 门店对账单 - {amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。 -
- { - paymentRef = e.target.value; - }} - /> -
- ), - okText: '确认打款', - cancelText: '取消', - onOk: async () => { - const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined }); - if (ids.length === 1) { - await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST', body }); - } else { - setBatchLoading(true); - try { - await request('/admin/store-bills/batch-confirm', { - method: 'POST', - body: JSON.stringify({ ids }), - }); - } finally { - setBatchLoading(false); - } - } - message.success('已确认打款'); - setSelectedKeys([]); - reload(); - }, - }); + setPayModal({ ids, amountHint }); } function approveWithdraw(id: string) { - let paymentRef = ''; - Modal.confirm({ - title: '审核通过并标记已结算?', - content: ( - { - paymentRef = e.target.value; - }} - /> - ), - okText: '通过并结算', - cancelText: '取消', - onOk: async () => { - await request(`/admin/store-withdrawals/${id}/approve`, { - method: 'POST', - body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }), - }); - message.success('已通过并标记已结算'); - setDrawerOpen(false); - reload(); - void request<{ pendingCount: number; overdueCount: number }>( - '/admin/store-withdrawals/overdue-summary', - ) - .then(setOverdueSummary) - .catch(() => {}); - }, - }); + setWithdrawApproveId(id); } function rejectWithdraw(id: string) { @@ -565,6 +507,9 @@ export default function StoreBillsPage() { {detail.paymentRef ? String(detail.paymentRef) : '—'} + + + {(detail.storeAccount as { bankAccountName?: string; @@ -649,6 +594,9 @@ export default function StoreBillsPage() { {detail.paymentRef ? String(detail.paymentRef) : '—'} + + + {storeAccount ? ( <> @@ -719,6 +667,71 @@ export default function StoreBillsPage() { )} + + setPayModal(null)} + onOk={async (payload) => { + if (!payModal) return; + const { ids } = payModal; + setPaySubmitting(true); + if (ids.length > 1) setBatchLoading(true); + try { + const body = JSON.stringify({ + ...payload, + ...(ids.length > 1 ? { ids } : {}), + }); + if (ids.length === 1) { + await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST', body }); + } else { + await request('/admin/store-bills/batch-confirm', { method: 'POST', body }); + } + message.success('已确认打款'); + setPayModal(null); + setSelectedKeys([]); + reload(); + } finally { + setPaySubmitting(false); + setBatchLoading(false); + } + }} + /> + + setWithdrawApproveId(null)} + onOk={async (payload) => { + if (!withdrawApproveId) return; + setWithdrawSubmitting(true); + try { + await request(`/admin/store-withdrawals/${withdrawApproveId}/approve`, { + method: 'POST', + body: JSON.stringify(payload), + }); + message.success('已通过并标记已结算'); + setWithdrawApproveId(null); + setDrawerOpen(false); + reload(); + void request<{ pendingCount: number; overdueCount: number }>( + '/admin/store-withdrawals/overdue-summary', + ) + .then(setOverdueSummary) + .catch(() => {}); + } finally { + setWithdrawSubmitting(false); + } + }} + /> ); } diff --git a/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx b/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx index 4842157..04d11ac 100644 --- a/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx +++ b/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx @@ -23,6 +23,7 @@ import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; import { AdminListHeader } from '../components/AdminListHeader'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; +import { FinancePayProofModal, PaymentProofGallery } from '../components/FinancePayProof'; type Row = { @@ -70,6 +71,8 @@ export default function StoreWithdrawalsPage() { pendingCount: number; overdueCount: number; } | null>(null); + const [approveId, setApproveId] = useState(null); + const [approveSubmitting, setApproveSubmitting] = useState(false); useEffect(() => { void request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) @@ -89,34 +92,7 @@ export default function StoreWithdrawalsPage() { } function approve(id: string) { - let paymentRef = ''; - Modal.confirm({ - title: '审核通过并标记已结算?', - content: ( - { - paymentRef = e.target.value; - }} - /> - ), - okText: '通过并结算', - cancelText: '取消', - onOk: async () => { - await request(`/admin/store-withdrawals/${id}/approve`, { - method: 'POST', - body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }), - }); - message.success('已通过并标记已结算'); - setDrawerOpen(false); - reload(); - void request<{ pendingCount: number; overdueCount: number }>( - '/admin/store-withdrawals/overdue-summary', - ) - .then(setOverdueSummary) - .catch(() => {}); - }, - }); + setApproveId(id); } function reject(id: string) { @@ -356,6 +332,9 @@ export default function StoreWithdrawalsPage() { {detail.paymentRef ? ( {String(detail.paymentRef)} ) : null} + + + {storeAccount?.bankAccountName || '—'} @@ -394,6 +373,36 @@ export default function StoreWithdrawalsPage() { ) : null} + + setApproveId(null)} + onOk={async (payload) => { + if (!approveId) return; + setApproveSubmitting(true); + try { + await request(`/admin/store-withdrawals/${approveId}/approve`, { + method: 'POST', + body: JSON.stringify(payload), + }); + message.success('已通过并标记已结算'); + setApproveId(null); + setDrawerOpen(false); + reload(); + void request<{ pendingCount: number; overdueCount: number }>( + '/admin/store-withdrawals/overdue-summary', + ) + .then(setOverdueSummary) + .catch(() => {}); + } finally { + setApproveSubmitting(false); + } + }} + /> ); } diff --git a/docs/杜康好客-v3-PRD.md b/docs/杜康好客-v3-PRD.md index 1947d79..41f8e0a 100644 --- a/docs/杜康好客-v3-PRD.md +++ b/docs/杜康好客-v3-PRD.md @@ -113,7 +113,7 @@ **HQ 财务打款信息**(`admin-web` 财务四页,v3.5.6): - 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行) -- 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示 +- 确认打款时可填写打款凭证号(`paymentRef`),并可上传凭证照片(`paymentProofUrls`,最多 9 张);已打款后在详情与导出中展示 - 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型) - 门店 T+1「出账日」= 出账当天(核销窗口「昨日 00:00–今日 00:00」中的今天) - 核销详情展示核销门店主账户收款信息 diff --git a/docs/杜康好客-v3-现状对照.md b/docs/杜康好客-v3-现状对照.md index da07916..b6f386c 100644 --- a/docs/杜康好客-v3-现状对照.md +++ b/docs/杜康好客-v3-现状对照.md @@ -71,6 +71,7 @@ | 日期 | 说明 | |------|------| +| 2026-08-30 | HQ 门店账单确认打款支持上传凭证照片(`payment_proof_urls`) | | 2026-08-26 | v3.5.14:`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` | | 2026-08-26 | v3.5.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 | | 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户(昵称+明文手机+HQ备注);`alert.settlement` 改名「结算任务失败通知」归入系统监控;补提现通过与四账单(城市/合伙人、笔数、累计金额、收款人账号开户行) | diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md index 6b23b5b..d4a4b92 100644 --- a/docs/杜康好客-v3编码手册.md +++ b/docs/杜康好客-v3编码手册.md @@ -54,6 +54,8 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd **用户日志端(v3.5.14)**:`order_submit` / `pay_success` 的 `clientApp` 取 JWT(小程序 `USER_MINI`);微信支付回调沿用该订单已有埋点,缺省小程序。禁止再写死 `USER_H5`。 +**HQ 门店账单打款凭证**:确认打款 / 提现通过可填 `paymentRef`,并可上传照片(`paymentProofUrls`,OSS `PAYMENT_PROOF`,最多 9 张)。详情与 T+1 导出展示。 + **合伙人关联与订单佣金(v4.0.1)**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `GET /partner/assoc` · `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword`、`assocPartnerAccountId`(`none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。 ## 5. 验收用例(必过) diff --git a/packages/shared-types/src/settlement.ts b/packages/shared-types/src/settlement.ts index 5db6d5e..38c1ae7 100644 --- a/packages/shared-types/src/settlement.ts +++ b/packages/shared-types/src/settlement.ts @@ -66,6 +66,14 @@ export interface StoreWithdrawSummaryDto { bankAccount?: StoreWithdrawBankAccountDto | null; } +/** 确认打款 / 提现通过时可附带的凭证照片上限 */ +export const PAYMENT_PROOF_IMAGE_MAX_COUNT = 9; + +export interface ConfirmFinancePayDto { + paymentRef?: string; + paymentProofUrls?: string[]; +} + export interface StoreWithdrawRequestDto { id: string; withdrawNo: string; @@ -78,6 +86,7 @@ export interface StoreWithdrawRequestDto { reviewedAt?: string | null; paidAt?: string | null; paymentRef?: string | null; + paymentProofUrls?: string[]; } export interface StorePayoutDto { @@ -171,6 +180,8 @@ export interface StoreBillDto { payoutAmount: number; status: FinancePayStatus; paidAt?: string | null; + paymentRef?: string | null; + paymentProofUrls?: string[]; } export interface WineryBillDto { diff --git a/server/dukang-api/prisma/migrate-store-bill-payment-proof.sql b/server/dukang-api/prisma/migrate-store-bill-payment-proof.sql new file mode 100644 index 0000000..ce7d149 --- /dev/null +++ b/server/dukang-api/prisma/migrate-store-bill-payment-proof.sql @@ -0,0 +1,5 @@ +-- 门店账单 / 手动提现:确认打款凭证照片 +ALTER TABLE `store_bill` + ADD COLUMN `payment_proof_urls` JSON NULL AFTER `payment_ref`; +ALTER TABLE `store_withdraw_request` + ADD COLUMN `payment_proof_urls` JSON NULL AFTER `payment_ref`; diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 5d488d9..e55e4f5 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -1953,10 +1953,11 @@ model StoreBill { redeemAmount Decimal @map("redeem_amount") @db.Decimal(10, 2) settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4) payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2) - status FinancePayStatus @default(UNPAID) - paymentRef String? @map("payment_ref") @db.VarChar(128) - paidAt DateTime? @map("paid_at") @db.DateTime(3) - createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + status FinancePayStatus @default(UNPAID) + paymentRef String? @map("payment_ref") @db.VarChar(128) + paymentProofUrls Json? @map("payment_proof_urls") + paidAt DateTime? @map("paid_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) payouts StorePayout[] @@ -2002,9 +2003,10 @@ model StoreWithdrawRequest { appliedAt DateTime @default(now()) @map("applied_at") @db.DateTime(3) reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3) reviewedByHqId BigInt? @map("reviewed_by_hq_id") @db.UnsignedBigInt - paidAt DateTime? @map("paid_at") @db.DateTime(3) - paymentRef String? @map("payment_ref") @db.VarChar(128) - createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + paidAt DateTime? @map("paid_at") @db.DateTime(3) + paymentRef String? @map("payment_ref") @db.VarChar(128) + paymentProofUrls Json? @map("payment_proof_urls") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index 1f1c2af..181cba8 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -133,7 +133,7 @@ export class AdminStoreWithdrawController { approve( @CurrentUser() user: AuthUser, @Param('id') id: string, - @Body() body: { paymentRef?: string }, + @Body() body: { paymentRef?: string; paymentProofUrls?: string[] }, ) { return this.settlementService.approveStoreWithdraw(BigInt(id), user.actorId, body); } @@ -287,8 +287,10 @@ export class AdminStoreBillController { batch: true, includeBody: true, }) - batchConfirm(@Body() body: { ids: string[] }) { - return this.settlementService.batchConfirmStoreBills(body.ids ?? []); + batchConfirm( + @Body() body: { ids: string[]; paymentRef?: string; paymentProofUrls?: string[] }, + ) { + return this.settlementService.batchConfirmStoreBills(body.ids ?? [], body); } @Get(':id') @@ -302,7 +304,10 @@ export class AdminStoreBillController { refType: 'STORE_BILL', refIdParam: 'id', }) - confirm(@Param('id') id: string, @Body() body: { paymentRef?: string }) { + confirm( + @Param('id') id: string, + @Body() body: { paymentRef?: string; paymentProofUrls?: string[] }, + ) { return this.settlementService.confirmStoreBill(BigInt(id), body); } } diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index 52f30ed..36518e5 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -4,6 +4,7 @@ import { DEFAULT_STORE_WITHDRAW_DAILY_LIMIT, DEFAULT_XFX_LOGISTICS_PRICING, LOGISTICS_SETTLEMENT_METHOD_LABELS, + PAYMENT_PROOF_IMAGE_MAX_COUNT, WINERY_SETTLEMENT_LAG_DAYS, WINERY_SETTLEMENT_RATE, } from '@dukang/shared-types'; @@ -50,6 +51,19 @@ function csvEscape(value: string) { return value; } +function parsePaymentProofUrls(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw + .map((u) => String(u ?? '').trim()) + .filter((u) => /^https?:\/\//i.test(u)) + .slice(0, PAYMENT_PROOF_IMAGE_MAX_COUNT); +} + +function paymentProofUrlsInput(urls?: string[]): Prisma.InputJsonValue | typeof Prisma.JsonNull { + const parsed = parsePaymentProofUrls(urls); + return parsed.length ? (parsed as Prisma.InputJsonValue) : Prisma.JsonNull; +} + /** 上海时区自然日 00:00(用本地 Date 构造;服务器需设 Asia/Shanghai 或等价) */ function startOfDay(d: Date) { return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0); @@ -715,6 +729,7 @@ export class SettlementService implements OnModuleInit { if (!row) throw new NotFoundException('提现申请不存在'); return serializeBigInt({ ...row, + paymentProofUrls: parsePaymentProofUrls(row.paymentProofUrls), overdue: row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt) : false, }); @@ -723,7 +738,7 @@ export class SettlementService implements OnModuleInit { async approveStoreWithdraw( id: bigint, hqAccountId: bigint, - dto?: { paymentRef?: string }, + dto?: { paymentRef?: string; paymentProofUrls?: string[] }, ) { const row = await this.prisma.storeWithdrawRequest.findUnique({ where: { id }, @@ -744,6 +759,7 @@ export class SettlementService implements OnModuleInit { reviewedByHqId: hqAccountId, paidAt, paymentRef: dto?.paymentRef?.trim() || null, + paymentProofUrls: paymentProofUrlsInput(dto?.paymentProofUrls), }, }); await tx.storePayout.updateMany({ @@ -764,6 +780,7 @@ export class SettlementService implements OnModuleInit { extraJson: { amount: Number(row.amount), paymentRef: dto?.paymentRef, + paymentProofCount: parsePaymentProofUrls(dto?.paymentProofUrls).length, }, }); @@ -1425,10 +1442,18 @@ export class SettlementService implements OnModuleInit { }); if (!bill) throw new NotFoundException('门店对账单不存在'); const storeAccount = await loadStorePrimaryBank(this.prisma, bill.storeId); - return serializeBigInt({ ...bill, billDate: shanghaiYmd(bill.billDate), storeAccount }); + return serializeBigInt({ + ...bill, + billDate: shanghaiYmd(bill.billDate), + storeAccount, + paymentProofUrls: parsePaymentProofUrls(bill.paymentProofUrls), + }); } - async confirmStoreBill(id: bigint, dto: { paymentRef?: string } = {}) { + async confirmStoreBill( + id: bigint, + dto: { paymentRef?: string; paymentProofUrls?: string[] } = {}, + ) { const bill = await this.prisma.storeBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('门店对账单不存在'); if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款'); @@ -1441,6 +1466,7 @@ export class SettlementService implements OnModuleInit { status: 'PAID', paidAt, paymentRef: dto.paymentRef?.trim() || null, + paymentProofUrls: paymentProofUrlsInput(dto.paymentProofUrls), }, }); await tx.storePayout.updateMany({ @@ -1449,14 +1475,20 @@ export class SettlementService implements OnModuleInit { }); return b; }); - return serializeBigInt(updated); + return serializeBigInt({ + ...updated, + paymentProofUrls: parsePaymentProofUrls(updated.paymentProofUrls), + }); } - async batchConfirmStoreBills(ids: string[]) { + async batchConfirmStoreBills( + ids: string[], + dto: { paymentRef?: string; paymentProofUrls?: string[] } = {}, + ) { const results: Array<{ id: string; ok: boolean; message?: string }> = []; for (const id of ids) { try { - await this.confirmStoreBill(BigInt(id)); + await this.confirmStoreBill(BigInt(id), dto); results.push({ id, ok: true }); } catch (e) { results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' }); @@ -1493,6 +1525,7 @@ export class SettlementService implements OnModuleInit { '状态', '打款时间', '打款凭证', + '打款凭证照片', '收款户名', '收款账号', '开户行', @@ -1511,6 +1544,7 @@ export class SettlementService implements OnModuleInit { b.status, b.paidAt ? b.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '', csvEscape(b.paymentRef ?? ''), + csvEscape(parsePaymentProofUrls(b.paymentProofUrls).join(' ')), csvEscape(bank?.bankAccountName ?? ''), csvEscape(bank?.bankAccountNo ?? ''), csvEscape(bank?.bankBranch ?? ''),