diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index 77f9db4..feeb3d2 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -1,293 +1,585 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; -import { isScanPermissionWarmupError } from '@dukang/weixin-sdk'; -import { useStoreSession } from '../contexts/StoreSessionContext'; -import { request } from '../lib/api'; -import { parseRedeemTokenFromScan } from '../lib/redeem-scan'; -import { - authorizeShopWechat, - checkNeedsWechatAuth, - fetchShopAccount, -} from '../lib/wechat-auth'; -import { isWechatEnv, weixinSdk } from '../lib/weixin'; -import { - clearPendingScanAfterAuth, - getPostAuthScanDelayMs, - markPendingScanAfterAuth, - peekPendingScanAfterAuth, -} from '../lib/shop-scan-auth'; -import WechatScanAuthModal from '../components/WechatScanAuthModal'; -import { useStorePageView } from '../lib/usePageView'; -import { trackStore } from '../lib/analytics'; - -function formatMoney(n: number) { - return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); -} - -function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string { - const msg = e instanceof Error ? e.message : '扫码失败,请重试'; - if (/invalid signature/i.test(msg)) { - return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试'; - } - if (isScanPermissionWarmupError(msg)) { - if (opts?.afterAuth) { - return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」'; - } - return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)'; - } - return msg; -} - -export default function HomePage() { - useStorePageView('store_home_view'); - const navigate = useNavigate(); - const { ready, authenticated } = useStoreSession(); - const [searchParams] = useSearchParams(); - const [dash, setDash] = useState | null>(null); - const [scanMsg, setScanMsg] = useState(''); - const [scanning, setScanning] = useState(false); - const [authModalOpen, setAuthModalOpen] = useState(false); - const [authLoading, setAuthLoading] = useState(false); - const [authError, setAuthError] = useState(''); - const pendingScanStartedRef = useRef(false); - - const loadDashboard = useCallback(() => { - return request>('SHOP_H5', '/shop/dashboard') - .then((d) => { - setDash(d); - }) - .catch(() => {}); - }, []); - - useEffect(() => { - void loadDashboard(); - }, [loadDashboard]); - - useEffect(() => { - function onResume() { - setScanning(false); - void loadDashboard(); - } - function onVisibility() { - if (document.visibilityState === 'visible') onResume(); - } - document.addEventListener('visibilitychange', onVisibility); - window.addEventListener('pageshow', onResume); - window.addEventListener('focus', onResume); - return () => { - document.removeEventListener('visibilitychange', onVisibility); - window.removeEventListener('pageshow', onResume); - window.removeEventListener('focus', onResume); - }; - }, [loadDashboard]); - - const runScan = useCallback( - async (opts?: { postAuthWarmup?: boolean }) => { - trackStore('store_redeem_scan_start'); - if (!isWechatEnv()) { - setScanMsg('请在微信内打开门店端进行扫码核销'); - return; - } - setScanning(true); - if (!opts?.postAuthWarmup) { - setScanMsg(''); - } - try { - if (opts?.postAuthWarmup) { - weixinSdk.reset(); - } - await weixinSdk.init(); - const raw = await weixinSdk.scanQrCode( - opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined, - ); - if (!raw) { - void loadDashboard(); - return; - } - const token = parseRedeemTokenFromScan(raw); - if (!token) { - setScanMsg('无法识别核销码,请扫描用户出示的核销二维码'); - return; - } - navigate(`/redeem?token=${encodeURIComponent(token)}`); - } catch (e) { - setScanMsg(formatScanError(e, { afterAuth: opts?.postAuthWarmup })); - } finally { - setScanning(false); - } - }, - [loadDashboard, navigate], - ); - - // OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫 - useEffect(() => { - if (!ready || !authenticated || !isWechatEnv()) return; - if (searchParams.get('code')) return; - if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return; - - pendingScanStartedRef.current = true; - clearPendingScanAfterAuth(); - setAuthModalOpen(false); - setAuthLoading(false); - setAuthError(''); - setScanMsg('微信授权成功,正在准备扫码…'); - - const timer = window.setTimeout(() => { - void runScan({ postAuthWarmup: true }); - }, getPostAuthScanDelayMs()); - - return () => window.clearTimeout(timer); - }, [ready, authenticated, searchParams, runScan]); - - async function handleScan() { - setScanMsg(''); - if (!isWechatEnv()) { - setScanMsg('请在微信内打开门店端进行扫码核销'); - return; - } - try { - const profile = await fetchShopAccount(); - if (await checkNeedsWechatAuth(profile)) { - pendingScanStartedRef.current = false; - setAuthModalOpen(true); - return; - } - await runScan(); - } catch (e) { - setScanMsg(e instanceof Error ? e.message : '无法发起扫码'); - } - } - - async function startWechatAuth() { - setAuthLoading(true); - setAuthError(''); - try { - pendingScanStartedRef.current = false; - markPendingScanAfterAuth(); - await authorizeShopWechat(); - } catch (e) { - clearPendingScanAfterAuth(); - setAuthError(e instanceof Error ? e.message : '微信授权失败'); - setAuthLoading(false); - } - } - - const store = dash?.store as Record | undefined; - const recent = (dash?.recentRecords as Array>) || []; - const status = String(store?.status || ''); - const open = status === 'OPEN'; - const hoursParts: string[] = []; - if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`); - if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`); - const hoursText = hoursParts.length ? hoursParts.join(',') : '10:00 - 22:00'; - const statusText = - status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店'; - - return ( - -
-

门店管理中心

-
- -
-
-
- store -

{String(store?.name || '门店')}

-
-
-
-

今日核销笔数

-

{Number(dash?.todayCount || 0)}

-

- 扫码 {Number(dash?.todayScanCount || 0)} · 手机号 {Number(dash?.todayPhoneCount || 0)} -

-
-
-

今日到账金额

-

- ¥ - {formatMoney(Number(dash?.todayAmount || 0))} -

-
-
-
- -
- -

{scanning ? '正在打开相机…' : '扫码核销'}

- {scanMsg &&

{scanMsg}

} - - smartphone - 手机号核销 - -
- -
-
-
- schedule -
-
-

营业状态

-

{statusText}

-

营业时间: {hoursText}

-
-
- -
- -
-
-

核销记录

- - 查看全部 - chevron_right - -
-
- {recent.length === 0 && ( -

暂无核销记录

- )} - {recent.map((r) => ( -
-
-

核销时间

-

- {new Date(String(r.createdAt)).toLocaleString('zh-CN')} -

-
-

¥{formatMoney(Number(r.amount))}

-
- ))} -
-
-
- - void startWechatAuth()} - onCancel={() => { - setAuthModalOpen(false); - setAuthError(''); - clearPendingScanAfterAuth(); - pendingScanStartedRef.current = false; - }} - /> -
- ); -} - \ No newline at end of file +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; + +import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; + +import { isScanPermissionWarmupError } from '@dukang/weixin-sdk'; + +import { useStoreSession } from '../contexts/StoreSessionContext'; + +import { request } from '../lib/api'; + +import { parseRedeemTokenFromScan } from '../lib/redeem-scan'; + +import { + + authorizeShopWechat, + + checkNeedsWechatAuth, + + fetchShopAccount, + +} from '../lib/wechat-auth'; + +import { isWechatEnv, weixinSdk } from '../lib/weixin'; + +import { + + clearPendingScanAfterAuth, + + getPostAuthScanDelayMs, + + markPendingScanAfterAuth, + + peekPendingScanAfterAuth, + +} from '../lib/shop-scan-auth'; + +import WechatScanAuthModal from '../components/WechatScanAuthModal'; + +import { useStorePageView } from '../lib/usePageView'; + +import { trackStore } from '../lib/analytics'; + + + +function formatMoney(n: number) { + + return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); + +} + + + +function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string { + + const msg = e instanceof Error ? e.message : '扫码失败,请重试'; + + if (/invalid signature/i.test(msg)) { + + return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试'; + + } + + if (isScanPermissionWarmupError(msg)) { + + if (opts?.afterAuth) { + + return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」'; + + } + + return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)'; + + } + + return msg; + +} + + + +export default function HomePage() { + + useStorePageView('store_home_view'); + + const navigate = useNavigate(); + + const { ready, authenticated } = useStoreSession(); + + const [searchParams] = useSearchParams(); + + const [dash, setDash] = useState | null>(null); + + const [scanMsg, setScanMsg] = useState(''); + + const [scanning, setScanning] = useState(false); + + const [authModalOpen, setAuthModalOpen] = useState(false); + + const [authLoading, setAuthLoading] = useState(false); + + const [authError, setAuthError] = useState(''); + + const pendingScanStartedRef = useRef(false); + + + + const loadDashboard = useCallback(() => { + + return request>('SHOP_H5', '/shop/dashboard') + + .then((d) => { + + setDash(d); + + }) + + .catch(() => {}); + + }, []); + + + + useEffect(() => { + + void loadDashboard(); + + }, [loadDashboard]); + + + + useEffect(() => { + + function onResume() { + + setScanning(false); + + void loadDashboard(); + + } + + function onVisibility() { + + if (document.visibilityState === 'visible') onResume(); + + } + + document.addEventListener('visibilitychange', onVisibility); + + window.addEventListener('pageshow', onResume); + + window.addEventListener('focus', onResume); + + return () => { + + document.removeEventListener('visibilitychange', onVisibility); + + window.removeEventListener('pageshow', onResume); + + window.removeEventListener('focus', onResume); + + }; + + }, [loadDashboard]); + + + + const runScan = useCallback( + + async (opts?: { postAuthWarmup?: boolean }) => { + + trackStore('store_redeem_scan_start'); + + if (!isWechatEnv()) { + + setScanMsg('请在微信内打开门店端进行扫码核销'); + + return; + + } + + setScanning(true); + + if (!opts?.postAuthWarmup) { + + setScanMsg(''); + + } + + try { + + if (opts?.postAuthWarmup) { + + weixinSdk.reset(); + + } + + await weixinSdk.init(); + + const raw = await weixinSdk.scanQrCode( + + opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined, + + ); + + if (!raw) { + + void loadDashboard(); + + return; + + } + + const token = parseRedeemTokenFromScan(raw); + + if (!token) { + + setScanMsg('无法识别核销码,请扫描用户出示的核销二维码'); + + return; + + } + + navigate(`/redeem?token=${encodeURIComponent(token)}`); + + } catch (e) { + + setScanMsg(formatScanError(e, { afterAuth: opts?.postAuthWarmup })); + + } finally { + + setScanning(false); + + } + + }, + + [loadDashboard, navigate], + + ); + + + + // OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫 + + useEffect(() => { + + if (!ready || !authenticated || !isWechatEnv()) return; + + if (searchParams.get('code')) return; + + if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return; + + + + pendingScanStartedRef.current = true; + + clearPendingScanAfterAuth(); + + setAuthModalOpen(false); + + setAuthLoading(false); + + setAuthError(''); + + setScanMsg('微信授权成功,正在准备扫码…'); + + + + const timer = window.setTimeout(() => { + + void runScan({ postAuthWarmup: true }); + + }, getPostAuthScanDelayMs()); + + + + return () => window.clearTimeout(timer); + + }, [ready, authenticated, searchParams, runScan]); + + + + async function handleScan() { + + setScanMsg(''); + + if (!isWechatEnv()) { + + setScanMsg('请在微信内打开门店端进行扫码核销'); + + return; + + } + + try { + + const profile = await fetchShopAccount(); + + if (await checkNeedsWechatAuth(profile)) { + + pendingScanStartedRef.current = false; + + setAuthModalOpen(true); + + return; + + } + + await runScan(); + + } catch (e) { + + setScanMsg(e instanceof Error ? e.message : '无法发起扫码'); + + } + + } + + + + async function startWechatAuth() { + + setAuthLoading(true); + + setAuthError(''); + + try { + + pendingScanStartedRef.current = false; + + markPendingScanAfterAuth(); + + await authorizeShopWechat(); + + } catch (e) { + + clearPendingScanAfterAuth(); + + setAuthError(e instanceof Error ? e.message : '微信授权失败'); + + setAuthLoading(false); + + } + + } + + + + const store = dash?.store as Record | undefined; + + const recent = (dash?.recentRecords as Array>) || []; + + const status = String(store?.status || ''); + + const open = status === 'OPEN'; + + const hoursParts: string[] = []; + + if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`); + + if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`); + + const hoursText = hoursParts.length ? hoursParts.join(',') : '10:00 - 22:00'; + + const statusText = + + status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店'; + + + + return ( + + + +
+ +

门店管理中心

+ +
+ + + +
+ +
+ +
+ + store + +

{String(store?.name || '门店')}

+ +
+ +
+ +
+ +

今日核销笔数

+ +

{Number(dash?.todayCount || 0)}

+ +

+ + 扫码 {Number(dash?.todayScanCount || 0)} · 手机号 {Number(dash?.todayPhoneCount || 0)} + +

+ +
+ +
+ +

今日到账金额

+ +

+ + ¥ + + {formatMoney(Number(dash?.todayAmount || 0))} + +

+ +
+ +
+ +
+ + + +
+ + + +

{scanning ? '正在打开相机…' : '扫码核销'}

+ + {scanMsg &&

{scanMsg}

} + + + + smartphone + + 手机号核销 + + + +
+ + + +
+ +
+ +
+ + schedule + +
+ +
+ +

营业状态

+ +

{statusText}

+ +

营业时间: {hoursText}

+ +
+ +
+ + + +
+ + + +
+ +
+ +

核销记录

+ + + + 查看全部 + + chevron_right + + + +
+ +
+ + {recent.length === 0 && ( + +

暂无核销记录

+ + )} + + {recent.map((r) => ( + +
+ +
+ +

核销时间

+ +

+ + {new Date(String(r.createdAt)).toLocaleString('zh-CN')} + +

+ +
+ +

¥{formatMoney(Number(r.amount))}

+ +
+ + ))} + +
+ +
+ +
+ + + + void startWechatAuth()} + + onCancel={() => { + + setAuthModalOpen(false); + + setAuthError(''); + + clearPendingScanAfterAuth(); + + pendingScanStartedRef.current = false; + + }} + + /> + +
+ + ); + +} + + diff --git a/apps/h5-user/src/components/ContactCustomerSheet.tsx b/apps/h5-user/src/components/ContactCustomerSheet.tsx index 6c81449..ddcdb87 100644 --- a/apps/h5-user/src/components/ContactCustomerSheet.tsx +++ b/apps/h5-user/src/components/ContactCustomerSheet.tsx @@ -1,6 +1,10 @@ -import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types'; +import { useEffect, useState } from 'react'; import { track } from '../lib/analytics'; -import { openWecomCustomerService } from '../lib/customer-service'; +import { + getCustomerServicePhone, + loadCustomerServicePhone, + openWecomCustomerService, +} from '../lib/customer-service'; type ContactCustomerSheetProps = { orderId?: string; @@ -9,7 +13,13 @@ type ContactCustomerSheetProps = { }; export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) { - const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, ''); + const [phone, setPhone] = useState(getCustomerServicePhone); + + useEffect(() => { + void loadCustomerServicePhone().then(setPhone); + }, []); + + const tel = phone.replace(/-/g, ''); function openPhone() { track('cs_contact', { type: 'phone', orderId }); @@ -41,7 +51,7 @@ export default function ContactCustomerSheet({ orderId, onClose }: ContactCustom

拨打总部客服电话

-

{CUSTOMER_SERVICE_PHONE}

+

{phone}

chevron_right diff --git a/apps/h5-user/src/lib/customer-service.ts b/apps/h5-user/src/lib/customer-service.ts index 41846aa..7aa6e50 100644 --- a/apps/h5-user/src/lib/customer-service.ts +++ b/apps/h5-user/src/lib/customer-service.ts @@ -1,12 +1,31 @@ import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types'; +import { fetchClientConfig } from './pay-wechat'; import { isWechatEnv } from './weixin'; +let cachedPhone = CUSTOMER_SERVICE_PHONE; + /** 企微客服链接:优先 Vite env,否则 shared-types 默认 */ export function getCustomerServiceWecomUrl(): string { const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim(); return fromEnv || CUSTOMER_SERVICE_WECOM_URL; } +export function getCustomerServicePhone(): string { + return cachedPhone; +} + +/** 从系统设置拉取客服电话(失败则保持默认常量) */ +export async function loadCustomerServicePhone(): Promise { + try { + const cfg = await fetchClientConfig(); + const phone = cfg.customerServicePhone?.trim(); + if (phone) cachedPhone = phone; + } catch { + /* keep fallback */ + } + return cachedPhone; +} + /** * 打开企业微信客服会话(须在微信内;需用户点击手势)。 * @returns true 已跳转;false 非微信环境已提示 @@ -20,4 +39,5 @@ export function openWecomCustomerService(): boolean { return true; } +/** @deprecated 请用 getCustomerServicePhone(),保留兼容旧引用 */ export { CUSTOMER_SERVICE_PHONE }; diff --git a/apps/h5-user/src/pages/CustomerServicePage.tsx b/apps/h5-user/src/pages/CustomerServicePage.tsx index 8aff409..3277a61 100644 --- a/apps/h5-user/src/pages/CustomerServicePage.tsx +++ b/apps/h5-user/src/pages/CustomerServicePage.tsx @@ -1,11 +1,22 @@ +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import SubPageHeader from '../components/SubPageHeader'; -import { CUSTOMER_SERVICE_PHONE, openWecomCustomerService } from '../lib/customer-service'; +import { + getCustomerServicePhone, + loadCustomerServicePhone, + openWecomCustomerService, +} from '../lib/customer-service'; import { track } from '../lib/analytics'; export default function CustomerServicePage() { const navigate = useNavigate(); - const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, ''); + const [phone, setPhone] = useState(getCustomerServicePhone); + + useEffect(() => { + void loadCustomerServicePhone().then(setPhone); + }, []); + + const tel = phone.replace(/-/g, ''); function openOnline() { track('cs_contact', { type: 'wecom_kf' }); @@ -27,7 +38,7 @@ export default function CustomerServicePage() { call - 或拨打客服电话 {CUSTOMER_SERVICE_PHONE} + 或拨打客服电话 {phone} diff --git a/apps/mini-user/package.json b/apps/mini-user/package.json index 0ec1f8b..fdce274 100644 --- a/apps/mini-user/package.json +++ b/apps/mini-user/package.json @@ -1,6 +1,6 @@ { "name": "@dukang/mini-user", - "version": "3.4.13", + "version": "3.4.14", "private": true, "description": "杜康好客 · C 端用户微信小程序(Taro)", "scripts": { diff --git a/apps/mini-user/src/app.config.ts b/apps/mini-user/src/app.config.ts index 3e74f5a..9414796 100644 --- a/apps/mini-user/src/app.config.ts +++ b/apps/mini-user/src/app.config.ts @@ -6,6 +6,7 @@ export default defineAppConfig({ 'pages/mine/index', 'pages/product-detail/index', 'pages/store-detail/index', + 'pages/store-package-detail/index', 'pages/order-confirm/index', 'pages/order-confirm-pickup/index', 'pages/pay/index', diff --git a/apps/mini-user/src/app.tsx b/apps/mini-user/src/app.tsx index bd8cca0..30c1ea7 100644 --- a/apps/mini-user/src/app.tsx +++ b/apps/mini-user/src/app.tsx @@ -6,11 +6,13 @@ import WechatShareBootstrap from './components/WechatShareBootstrap'; 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 './app.css'; // H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime patchTaroH5Hooks(); installClientErrorReporting(); +prefetchShareBrandAssets(); function App({ children }: PropsWithChildren) { const handlingRef = useRef(false); diff --git a/apps/mini-user/src/components/ProductCarousel.tsx b/apps/mini-user/src/components/ProductCarousel.tsx index eb3ca12..bb6809a 100644 --- a/apps/mini-user/src/components/ProductCarousel.tsx +++ b/apps/mini-user/src/components/ProductCarousel.tsx @@ -7,6 +7,8 @@ type ProductCarouselProps = { alt: string; variant?: 'home' | 'detail' | 'store'; previewable?: boolean; + /** cover=aspectFill 裁剪铺满;contain=aspectFit 缩放完整显示(门店门头固定区) */ + imageFit?: 'cover' | 'contain'; }; /** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */ @@ -15,12 +17,14 @@ export default function ProductCarousel({ alt, variant = 'detail', previewable = false, + imageFit = 'cover', }: ProductCarouselProps) { const slides = images.length > 0 ? images : ['']; const [activeIndex, setActiveIndex] = useState(0); const prefix = variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel'; - const imageMode = 'aspectFill'; + const isContain = imageFit === 'contain'; + const wrapClass = `${prefix}-wrap${isContain ? ` ${prefix}-wrap--contain` : ''}`; function previewAt(index: number) { const urls = slides.filter(Boolean); @@ -30,7 +34,7 @@ export default function ProductCarousel({ } return ( - + 1} @@ -42,7 +46,7 @@ export default function ProductCarousel({ {alt} previewAt(index) : undefined} /> diff --git a/apps/mini-user/src/lib/brand-assets.ts b/apps/mini-user/src/lib/brand-assets.ts new file mode 100644 index 0000000..8aef9e0 --- /dev/null +++ b/apps/mini-user/src/lib/brand-assets.ts @@ -0,0 +1,68 @@ +import { + BRAND_LOGO_MARK_URL, + BRAND_LOGO_URL, + BRAND_LOGO_WIDE_URL, + CUSTOMER_SERVICE_PHONE, + QUALIFICATION_DISCLOSURE_URL, + type ClientRuntimeConfig, +} from '@dukang/shared-types'; +import { fetchClientConfig } from './pay-wechat'; + +export type BrandAssets = { + brandLogoUrl: string; + brandLogoWideUrl: string; + brandLogoMarkUrl: string; + qualificationDisclosureUrl: string; + customerServicePhone: string; +}; + +const FALLBACK: BrandAssets = { + brandLogoUrl: BRAND_LOGO_URL, + brandLogoWideUrl: BRAND_LOGO_WIDE_URL, + brandLogoMarkUrl: BRAND_LOGO_MARK_URL, + qualificationDisclosureUrl: QUALIFICATION_DISCLOSURE_URL, + customerServicePhone: CUSTOMER_SERVICE_PHONE, +}; + +let cached: BrandAssets | null = null; +let inflight: Promise | null = null; + +function fromConfig(config: ClientRuntimeConfig | null | undefined): BrandAssets { + return { + brandLogoUrl: config?.brandLogoUrl?.trim() || FALLBACK.brandLogoUrl, + brandLogoWideUrl: config?.brandLogoWideUrl?.trim() || FALLBACK.brandLogoWideUrl, + brandLogoMarkUrl: config?.brandLogoMarkUrl?.trim() || FALLBACK.brandLogoMarkUrl, + qualificationDisclosureUrl: + config?.qualificationDisclosureUrl?.trim() || FALLBACK.qualificationDisclosureUrl, + customerServicePhone: config?.customerServicePhone?.trim() || FALLBACK.customerServicePhone, + }; +} + +/** 同步读取最近一次缓存(未拉取前返回代码默认常量) */ +export function getBrandAssetsSync(): BrandAssets { + return cached ?? FALLBACK; +} + +/** 拉取 /common/client-config 中的品牌与客服配置并缓存 */ +export async function loadBrandAssets(force = false): Promise { + if (!force && cached) return cached; + if (!force && inflight) return inflight; + inflight = fetchClientConfig() + .then((cfg) => { + cached = fromConfig(cfg); + return cached; + }) + .catch(() => { + cached = FALLBACK; + return cached; + }) + .finally(() => { + inflight = null; + }); + return inflight; +} + +export function applyBrandFromClientConfig(config: ClientRuntimeConfig | null | undefined) { + cached = fromConfig(config); + return cached; +} diff --git a/apps/mini-user/src/lib/client-version.ts b/apps/mini-user/src/lib/client-version.ts index 45fa6a1..1e83a3b 100644 --- a/apps/mini-user/src/lib/client-version.ts +++ b/apps/mini-user/src/lib/client-version.ts @@ -2,7 +2,7 @@ import Taro from '@tarojs/taro'; import { fetchClientConfig } from './pay-wechat'; /** 与 package.json version 同步,供服务端 minClientVersion 比对 */ -export const APP_VERSION = '3.4.13'; +export const APP_VERSION = '3.4.14'; export const APP_VERSION_LABEL = `v${APP_VERSION}`; diff --git a/apps/mini-user/src/lib/wechat-share.ts b/apps/mini-user/src/lib/wechat-share.ts index 1a7ece0..a709f09 100644 --- a/apps/mini-user/src/lib/wechat-share.ts +++ b/apps/mini-user/src/lib/wechat-share.ts @@ -1,16 +1,21 @@ import Taro from '@tarojs/taro'; -import { BRAND_LOGO_URL } from '@dukang/shared-types'; import type { WechatShareData } from '@dukang/weixin-sdk'; import { getWechatShareLink, isWechatBrowser, isMiniProgram } from '@dukang/weixin-sdk'; import { toast } from './api'; +import { getBrandAssetsSync, loadBrandAssets } from './brand-assets'; import { isWechatEnv, weixinSdk } from './weixin'; -export const DEFAULT_SHARE_TITLE = '你吃饭,杜康买单'; +export const DEFAULT_SHARE_TITLE = '你吃饭,我买单'; export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用'; export const WECHAT_SHARE_HINT = '请点击右上角 ··· 分享给好友'; export function getDefaultShareImageUrl(): string { - return BRAND_LOGO_URL; + return getBrandAssetsSync().brandLogoUrl; +} + +/** 预热分享默认图(来自系统设置) */ +export function prefetchShareBrandAssets() { + void loadBrandAssets(); } export function buildDefaultShareData( diff --git a/apps/mini-user/src/pages/customer-service/index.tsx b/apps/mini-user/src/pages/customer-service/index.tsx index fe448fd..b6b6b3e 100644 --- a/apps/mini-user/src/pages/customer-service/index.tsx +++ b/apps/mini-user/src/pages/customer-service/index.tsx @@ -1,19 +1,26 @@ +import { useEffect, useState } from 'react'; import { View, Text } from '@tarojs/components'; import Taro from '@tarojs/taro'; -import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; import ContactCsButton from '../../components/ContactCsButton'; import { toast } from '../../lib/api'; +import { getBrandAssetsSync, loadBrandAssets } from '../../lib/brand-assets'; const isWeapp = process.env.TARO_ENV === 'weapp'; -const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, ''); -function dialPhone() { - Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() => toast('无法拨打电话')); +function dialPhone(phone: string) { + const tel = phone.replace(/-/g, ''); + Taro.makePhoneCall({ phoneNumber: tel }).catch(() => toast('无法拨打电话')); } export default function CustomerServicePage() { + const [phone, setPhone] = useState(() => getBrandAssetsSync().customerServicePhone); + + useEffect(() => { + void loadBrandAssets().then((b) => setPhone(b.customerServicePhone)); + }, []); + return ( @@ -30,14 +37,14 @@ export default function CustomerServicePage() { ) : null} - + dialPhone(phone)}> - {isWeapp ? `或拨打客服电话 ${CUSTOMER_SERVICE_PHONE}` : '拨打客服电话'} + {isWeapp ? `或拨打客服电话 ${phone}` : '拨打客服电话'} {!isWeapp ? ( - {CUSTOMER_SERVICE_PHONE} + {phone} ) : null} diff --git a/apps/mini-user/src/pages/login/index.tsx b/apps/mini-user/src/pages/login/index.tsx index 2eb53f8..b2f2ff7 100644 --- a/apps/mini-user/src/pages/login/index.tsx +++ b/apps/mini-user/src/pages/login/index.tsx @@ -10,7 +10,11 @@ import { import PageShell from '../../components/PageShell'; import WechatLoginButton from '../../components/WechatLoginButton'; import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton'; -import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types'; +import { + applyBrandFromClientConfig, + getBrandAssetsSync, + loadBrandAssets, +} from '../../lib/brand-assets'; import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav'; import { bindWechatForUser, @@ -98,11 +102,18 @@ export default function LoginPage() { const [wxAuthorize, setWxAuthorize] = useState(true); const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null); const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP); + const [logoWideUrl, setLogoWideUrl] = useState(() => getBrandAssetsSync().brandLogoWideUrl); useEffect(() => { request('/common/client-config') - .then((config) => setWxAuthorize(isWxAuthorizeEnabled(config))) - .catch(() => setWxAuthorize(true)); + .then((config) => { + setWxAuthorize(isWxAuthorizeEnabled(config)); + setLogoWideUrl(applyBrandFromClientConfig(config).brandLogoWideUrl); + }) + .catch(() => { + setWxAuthorize(true); + void loadBrandAssets().then((b) => setLogoWideUrl(b.brandLogoWideUrl)); + }); }, []); useEffect(() => { @@ -394,7 +405,7 @@ export default function LoginPage() { - + 官方 diff --git a/apps/mini-user/src/pages/mine/index.tsx b/apps/mini-user/src/pages/mine/index.tsx index 9cae36c..fcedce3 100644 --- a/apps/mini-user/src/pages/mine/index.tsx +++ b/apps/mini-user/src/pages/mine/index.tsx @@ -2,8 +2,6 @@ import { useEffect, useMemo, useState } from 'react'; import { View, Text, Image, Button, Input, ScrollView } from '@tarojs/components'; import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro'; import { - BRAND_LOGO_MARK_URL, - QUALIFICATION_DISCLOSURE_URL, isWxAuthorizeEnabled, type ClientRuntimeConfig, } from '@dukang/shared-types'; @@ -13,6 +11,11 @@ import WechatShareReady from '../../components/WechatShareReady'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; import { goLogin } from '../../lib/auth-nav'; import { bindWechatForUser } from '../../lib/wechat-auth'; +import { + applyBrandFromClientConfig, + getBrandAssetsSync, + loadBrandAssets, +} from '../../lib/brand-assets'; import { fetchMiniWechatUserInfo, isDefaultMiniNickname, @@ -70,6 +73,10 @@ export default function MinePage() { const [savingProfile, setSavingProfile] = useState(false); const [profileLoadError, setProfileLoadError] = useState(''); const [qualificationOpen, setQualificationOpen] = useState(false); + const [brandMarkUrl, setBrandMarkUrl] = useState(() => getBrandAssetsSync().brandLogoMarkUrl); + const [qualificationUrl, setQualificationUrl] = useState( + () => getBrandAssetsSync().qualificationDisclosureUrl, + ); function resetGuestState() { setProfile(null); @@ -141,8 +148,16 @@ export default function MinePage() { useEffect(() => { request('/common/client-config') - .then((config) => setWxAuthorize(isWxAuthorizeEnabled(config))) - .catch(() => setWxAuthorize(true)); + .then((config) => { + setWxAuthorize(isWxAuthorizeEnabled(config)); + const brand = applyBrandFromClientConfig(config); + setBrandMarkUrl(brand.brandLogoMarkUrl); + setQualificationUrl(brand.qualificationDisclosureUrl); + }) + .catch(() => { + setWxAuthorize(true); + void loadBrandAssets(); + }); }, []); const sharePayload = useMemo( @@ -304,7 +319,7 @@ export default function MinePage() { if (displayAvatarUrl) { return ; } - return ; + return ; } if (!authed) { @@ -317,7 +332,7 @@ export default function MinePage() { goLogin('/pages/mine/index')}> - + @@ -526,7 +541,7 @@ export default function MinePage() { {previewAvatar ? ( ) : ( - + )} 点击选择头像 @@ -579,7 +594,7 @@ export default function MinePage() { diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index cdaf89a..f28ad37 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { View, Text, Image, ScrollView } from '@tarojs/components'; +import { View, Text, Image } from '@tarojs/components'; import Taro, { useDidShow, useLoad, @@ -156,14 +156,9 @@ export default function StoreDetailPage() { const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [headerSolid, setHeaderSolid] = useState(false); - const [activePackageIndex, setActivePackageIndex] = useState(0); const storeRef = useRef(null); storeRef.current = store; - useEffect(() => { - setActivePackageIndex(0); - }, [store?.id]); - usePageScroll(({ scrollTop }) => { setHeaderSolid(scrollTop > 100); }); @@ -326,13 +321,18 @@ export default function StoreDetailPage() { const envPhotos = envPhotoUrls(store); const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]); const packages = store.packages ?? []; - const activePackage = packages[activePackageIndex] ?? packages[0]; const intro = store.intro?.trim() || ''; const benefitRuleRaw = store.benefitUsageRule?.trim() || ''; const benefitRule = benefitRuleRaw && !/^null$/i.test(benefitRuleRaw) ? benefitRuleRaw : ''; + function openPackageDetail(index: number) { + Taro.navigateTo({ + url: `/pages/store-package-detail/index?storeId=${storeId}&index=${index}`, + }); + } + function previewEnv(index: number) { if (!envPhotos.length) return; Taro.previewImage({ @@ -353,7 +353,13 @@ export default function StoreDetailPage() { /> - + @@ -408,48 +414,19 @@ export default function StoreDetailPage() { ) : null} - {packages.length > 0 && activePackage ? ( + {packages.length > 0 ? ( 门店套餐 - {packages.length > 1 ? ( - - - {packages.map((pkg, index) => ( - setActivePackageIndex(index)} - > - {pkg.name} - - ))} + + {packages.map((pkg, index) => ( + openPackageDetail(index)} + > + {pkg.name} - - ) : null} - - {activePackage.imageUrl ? ( - - ) : null} - - {packages.length === 1 ? ( - {activePackage.name} - ) : null} - - {formatRedeemAmountYuan(activePackage.price)} 元 · {activePackage.dishes} - - {activePackage.usableTime ? ( - 使用时间:{activePackage.usableTime} - ) : null} - {activePackage.otherNotes ? ( - 说明:{activePackage.otherNotes} - ) : null} - + ))} ) : null} diff --git a/apps/mini-user/src/pages/store-package-detail/index.config.ts b/apps/mini-user/src/pages/store-package-detail/index.config.ts new file mode 100644 index 0000000..0eeabd1 --- /dev/null +++ b/apps/mini-user/src/pages/store-package-detail/index.config.ts @@ -0,0 +1,4 @@ +export default definePageConfig({ + navigationStyle: 'custom', + navigationBarTitleText: '套餐详情', +}); \ No newline at end of file diff --git a/apps/mini-user/src/pages/store-package-detail/index.tsx b/apps/mini-user/src/pages/store-package-detail/index.tsx new file mode 100644 index 0000000..159d7fb --- /dev/null +++ b/apps/mini-user/src/pages/store-package-detail/index.tsx @@ -0,0 +1,175 @@ +import { useCallback, useState } from 'react'; +import { View, Text, Image } from '@tarojs/components'; +import Taro, { useLoad, useRouter } from '@tarojs/taro'; +import PageShell from '../../components/PageShell'; +import PageNavBar from '../../components/PageNavBar'; +import { request, toast } from '../../lib/api'; + +type StorePackage = { + name: string; + price: string | number; + dishes: string; + usableTime?: string | null; + otherNotes?: string | null; + imageUrl?: string | null; +}; + +type Store = { + id: string; + name: string; + packages?: StorePackage[] | null; +}; + +function pickStoreId(raw?: string | null) { + return String(raw || '') + .trim() + .replace(/[^\d]/g, ''); +} + +function parsePackageIndex(raw?: string | null) { + const n = Number.parseInt(String(raw ?? ''), 10); + return Number.isFinite(n) && n >= 0 ? n : -1; +} + +function formatPriceYuan(price: string | number) { + const n = typeof price === 'number' ? price : Number(price); + if (!Number.isFinite(n)) return '0'; + if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n)); + return n.toFixed(2).replace(/\.?0+$/, ''); +} + +export default function StorePackageDetailPage() { + const router = useRouter(); + const [storeId, setStoreId] = useState(() => pickStoreId(router.params.storeId)); + const [storeName, setStoreName] = useState(''); + const [pkg, setPkg] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + + const loadPackage = useCallback(async (sid: string, index: number) => { + if (!sid) { + setLoading(false); + setLoadError('缺少门店参数'); + return; + } + if (index < 0) { + setLoading(false); + setLoadError('套餐不存在'); + return; + } + setLoadError(''); + setLoading(true); + try { + const data = await request(`/stores/${sid}`); + const packages = data?.packages ?? []; + const item = packages[index]; + if (!data?.id || !item) { + setPkg(null); + setLoadError('套餐不存在或已下架'); + toast('套餐不存在或已下架'); + return; + } + setStoreName(data.name); + setPkg(item); + } catch (e) { + const msg = e instanceof Error ? e.message : '加载失败'; + setLoadError(msg); + toast(msg); + } finally { + setLoading(false); + } + }, []); + + useLoad((options) => { + const sid = pickStoreId(options?.storeId || router.params.storeId); + const index = parsePackageIndex(options?.index ?? router.params.index); + setStoreId(sid); + void loadPackage(sid, index); + }); + + function goBack() { + const pages = Taro.getCurrentPages(); + if (pages.length > 1) Taro.navigateBack(); + else if (storeId) { + Taro.redirectTo({ url: `/pages/store-detail/index?id=${storeId}` }); + } else { + Taro.switchTab({ url: '/pages/stores/index' }); + } + } + + function previewImage(url: string) { + Taro.previewImage({ urls: [url], current: url }).catch(() => toast('无法预览图片')); + } + + if (loading) { + return ( + + + 加载中… + + ); + } + + if (!pkg) { + return ( + + + {loadError || '套餐不存在'} + + ); + } + + const imageUrl = (pkg.imageUrl || '').trim(); + + return ( + + + + + + + + {pkg.name} + ¥{formatPriceYuan(pkg.price)} + + {storeName ? ( + {storeName} + ) : null} + + + {imageUrl ? ( + previewImage(imageUrl)} + > + + + ) : null} + + + + 菜品 + {pkg.dishes || '—'} + + {pkg.usableTime ? ( + + 使用时间 + {pkg.usableTime} + + ) : null} + {pkg.otherNotes ? ( + + 其他说明 + {pkg.otherNotes} + + ) : null} + + + + + ); +} diff --git a/apps/mini-user/src/pages/stores/index.tsx b/apps/mini-user/src/pages/stores/index.tsx index 0b00f41..f33e287 100644 --- a/apps/mini-user/src/pages/stores/index.tsx +++ b/apps/mini-user/src/pages/stores/index.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { View, Text, Image, Input } from '@tarojs/components'; import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro'; -import { StoreStatus, STORE_STATUS_LABELS } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; import TabMainHeader from '../../components/TabMainHeader'; import WechatShareReady from '../../components/WechatShareReady'; @@ -342,17 +341,12 @@ export default function StoresPage() { } } - function formatHours(store: Store) { + function hoursLines(store: Store): string[] { const parts: string[] = []; if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`); if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`); - return parts.length ? `营业时间: ${parts.join(',')}` : '营业时间: 10:00-22:00'; - } - - function formatStatus(store: Store) { - const status = store.status as StoreStatus | undefined; - if (status && STORE_STATUS_LABELS[status]) return STORE_STATUS_LABELS[status]; - return STORE_STATUS_LABELS[StoreStatus.OPEN]; + if (!parts.length) parts.push('10:00-22:00'); + return parts.map((p, i) => (i === 0 ? `营业时间: ${p}` : p)); } const sharePayload = useMemo( @@ -446,14 +440,17 @@ export default function StoresPage() { {formatDistanceMeters(s.distanceMeters)} - {/* 第2行:状态 + 营业时间(含第二段) */} - - {formatStatus(s)} - {formatHours(s)} + {/* 第2行:营业时间(多段各占一行,居左) */} + + {hoursLines(s).map((line) => ( + + {line} + + ))} - {/* 第3行:地址 + 去核销 */} + {/* 第3行:地址(最多两行)+ 去核销 */} - + {s.address || (s.district ? `${s.district}` : '地址待完善')} , +): ClientBrandRuntime { + const e = readEnv(env); + const brandBase = (e.BRAND_LOGO_OSS_BASE || BRAND_LOGO_OSS_BASE).trim().replace(/\/*$/, '/'); + const staticBase = (e.MINI_USER_STATIC_OSS_BASE || MINI_USER_STATIC_OSS_BASE) + .trim() + .replace(/\/*$/, '/'); + return { + userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''), + brandLogoOssBase: brandBase, + brandLogoUrl: (e.BRAND_LOGO_URL || '').trim() || `${brandBase}logo.png`, + brandLogoWideUrl: (e.BRAND_LOGO_WIDE_URL || '').trim() || `${brandBase}logo1.png`, + brandLogoMarkUrl: (e.BRAND_LOGO_MARK_URL || '').trim() || `${brandBase}logo2.png`, + miniUserStaticOssBase: staticBase, + qualificationDisclosureUrl: + (e.QUALIFICATION_DISCLOSURE_URL || '').trim() || + `${staticBase}qualification-disclosure.png`, + customerServicePhone: (e.CUSTOMER_SERVICE_PHONE || CUSTOMER_SERVICE_PHONE).trim(), + }; +} + +/** Mock 短信固定验证码(系统设置 MOCK_SMS_FIXED_CODE 可覆盖) */ +export function resolveMockSmsFixedCode(env?: Record): string { + const code = (readEnv(env).MOCK_SMS_FIXED_CODE || '').trim(); + return code || MOCK_SMS_FIXED_CODE; +} + function readEnv(env?: Record) { return ( env ?? @@ -142,5 +182,5 @@ export function loadAppConfig(env?: Record): AppConf }; } -/** Mock 短信环境固定验证码 */ +/** Mock 短信环境固定验证码(默认;系统设置 MOCK_SMS_FIXED_CODE 可覆盖) */ export const MOCK_SMS_FIXED_CODE = '999888'; diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts index b2d8ef8..d5709c6 100644 --- a/packages/shared-types/src/wechat.ts +++ b/packages/shared-types/src/wechat.ts @@ -50,6 +50,18 @@ export type ClientRuntimeConfig = { }; /** 小程序最低兼容版本(semver,如 3.4.13);客户端低于此值时提示更新 */ minClientVersion?: string | null; + /** C 端 H5 落地页(推广码等) */ + userH5Url?: string; + /** 方形品牌 Logo */ + brandLogoUrl?: string; + /** 长方形品牌 Logo(登录) */ + brandLogoWideUrl?: string; + /** 图标 Logo(默认头像) */ + brandLogoMarkUrl?: string; + /** 「我的」资质公示长图 */ + qualificationDisclosureUrl?: string; + /** 总部客服电话 */ + customerServicePhone?: string; }; /** 是否展示微信授权入口 */ diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts index 95eaf93..81bc53d 100644 --- a/server/dukang-api/src/common/system-config/system-config.registry.ts +++ b/server/dukang-api/src/common/system-config/system-config.registry.ts @@ -1,4 +1,15 @@ import type { SystemConfigFieldMeta, SystemConfigGroupMeta } from '@dukang/shared-types'; +import { + BRAND_LOGO_MARK_URL, + BRAND_LOGO_OSS_BASE, + BRAND_LOGO_URL, + BRAND_LOGO_WIDE_URL, + CUSTOMER_SERVICE_PHONE, + DEFAULT_USER_H5_URL, + MINI_USER_STATIC_OSS_BASE, + MOCK_SMS_FIXED_CODE, + QUALIFICATION_DISCLOSURE_URL, +} from '@dukang/shared-types'; /** 运行环境、安全与鉴权仅保留在 .env,不在 HQ 系统设置中维护 */ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [ @@ -122,9 +133,86 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [ group: G.wechat_mini, type: 'string', requiresRestart: false, - placeholder: '3.4.13', + placeholder: '3.4.14', description: 'semver 格式;客户端低于此版本时提示更新', }, + { + key: 'USER_H5_URL', + label: 'C 端 H5 落地页', + group: G.wechat_mini, + type: 'string', + requiresRestart: false, + placeholder: 'https://user.example.com/user', + description: '推广码二维码 / 未配置时的默认落地页前缀(无末尾斜杠)', + }, + { + key: 'BRAND_LOGO_OSS_BASE', + label: '品牌 Logo OSS 根路径', + group: G.wechat_mini, + type: 'string', + requiresRestart: false, + placeholder: 'https://xxx.oss-cn-beijing.aliyuncs.com/logo/', + description: '仅作说明/备份;下方三张 Logo 请直接配置完整 URL', + }, + { + key: 'BRAND_LOGO_URL', + label: '方形 Logo', + group: G.wechat_mini, + type: 'image', + requiresRestart: false, + description: '分享卡片默认图、首页等品牌展示', + }, + { + key: 'BRAND_LOGO_WIDE_URL', + label: '长方形 Logo', + group: G.wechat_mini, + type: 'image', + requiresRestart: false, + description: '含文字,登录页等场景', + }, + { + key: 'BRAND_LOGO_MARK_URL', + label: '图标 Logo', + group: G.wechat_mini, + type: 'image', + requiresRestart: false, + description: '默认头像(未微信授权时)', + }, + { + key: 'MINI_USER_STATIC_OSS_BASE', + label: '小程序静态资源 OSS 根路径', + group: G.wechat_mini, + type: 'string', + requiresRestart: false, + placeholder: 'https://xxx.oss-cn-beijing.aliyuncs.com/static/mini-user/', + description: '资质公示等静态资源根路径;完整 URL 优先用下方「资质公示图」', + }, + { + key: 'QUALIFICATION_DISCLOSURE_URL', + label: '资质公示长图', + group: G.wechat_mini, + type: 'image', + requiresRestart: false, + description: '「我的」页资质公示大图', + }, + { + key: 'CUSTOMER_SERVICE_PHONE', + label: '总部客服电话', + group: G.wechat_mini, + type: 'string', + requiresRestart: false, + placeholder: '13203801799', + description: 'C 端联系客服拨号号码', + }, + { + key: 'MOCK_SMS_FIXED_CODE', + label: 'Mock 短信固定验证码', + group: G.wechat_mini, + type: 'string', + requiresRestart: false, + placeholder: '999888', + description: '仅 MOCK_SMS 开启时生效;留空则用默认 999888', + }, { key: 'OSS_ACCESS_KEY_ID', label: 'OSS AccessKey ID', group: G.oss, type: 'password', secret: true, requiresRestart: true }, { key: 'OSS_ACCESS_KEY_SECRET', label: 'OSS AccessKey Secret', group: G.oss, type: 'password', secret: true, requiresRestart: true }, @@ -137,7 +225,6 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [ { key: 'OSS_UPLOAD_EXPIRE_SECONDS', label: '直传凭证有效期(秒)', group: G.oss, type: 'number', requiresRestart: false }, { key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false }, - { key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false }, { key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key', @@ -247,6 +334,23 @@ export const SYSTEM_CONFIG_RETIRED_KEYS = [ export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.key)); +/** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */ +export const SYSTEM_CONFIG_DEFAULTS: Record = { + USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''), + BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE, + BRAND_LOGO_URL: BRAND_LOGO_URL, + BRAND_LOGO_WIDE_URL: BRAND_LOGO_WIDE_URL, + BRAND_LOGO_MARK_URL: BRAND_LOGO_MARK_URL, + MINI_USER_STATIC_OSS_BASE: MINI_USER_STATIC_OSS_BASE, + QUALIFICATION_DISCLOSURE_URL: QUALIFICATION_DISCLOSURE_URL, + CUSTOMER_SERVICE_PHONE: CUSTOMER_SERVICE_PHONE, + MOCK_SMS_FIXED_CODE: MOCK_SMS_FIXED_CODE, +}; + export function getSystemConfigField(key: string): SystemConfigFieldMeta | undefined { return SYSTEM_CONFIG_FIELDS.find((f) => f.key === key); } + +export function getSystemConfigDefault(key: string): string { + return SYSTEM_CONFIG_DEFAULTS[key] ?? ''; +} diff --git a/server/dukang-api/src/common/system-config/system-config.service.ts b/server/dukang-api/src/common/system-config/system-config.service.ts index bc54729..b9794ed 100644 --- a/server/dukang-api/src/common/system-config/system-config.service.ts +++ b/server/dukang-api/src/common/system-config/system-config.service.ts @@ -8,6 +8,7 @@ import type { import { loadAppConfig, parseMiniHomeBanners, serializeMiniHomeBanners } from '@dukang/shared-types'; import { PrismaService } from '../prisma/prisma.module'; import { + SYSTEM_CONFIG_DEFAULTS, SYSTEM_CONFIG_FIELDS, SYSTEM_CONFIG_GROUPS, SYSTEM_CONFIG_KEY_SET, @@ -42,6 +43,7 @@ export class SystemConfigService implements OnModuleInit { try { await this.purgeRetiredKeys(); await this.seedMissingFromProcessEnv(); + await this.seedMissingDefaults(); const rows = await this.prisma.systemConfig.findMany(); applyEnvOverlay(Object.fromEntries(rows.map((r) => [r.configKey, r.value]))); this.lastUpdatedAt = rows.reduce( @@ -82,7 +84,7 @@ export class SystemConfigService implements OnModuleInit { for (const field of fields) { const fromDb = dbMap.get(field.key); const fromEnv = process.env[field.key]; - const raw = fromDb ?? fromEnv ?? ''; + const raw = (fromDb ?? fromEnv ?? SYSTEM_CONFIG_DEFAULTS[field.key] ?? '').trim(); if (field.secret) { if (raw) configuredSecrets.push(field.key); values[field.key] = ''; @@ -227,6 +229,26 @@ export class SystemConfigService implements OnModuleInit { } } + /** 将代码默认值写入空缺配置,便于 HQ 表单可见、可改 */ + private async seedMissingDefaults() { + const overlay: Record = {}; + for (const field of SYSTEM_CONFIG_FIELDS) { + const defaultVal = SYSTEM_CONFIG_DEFAULTS[field.key]; + if (!defaultVal?.trim()) continue; + const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } }); + if (existing?.value?.trim()) continue; + if (process.env[field.key]?.trim()) continue; + const value = this.normalizeByMeta(field, defaultVal); + await this.prisma.systemConfig.upsert({ + where: { configKey: field.key }, + create: { configKey: field.key, value }, + update: { value }, + }); + overlay[field.key] = value; + } + if (Object.keys(overlay).length) applyEnvOverlay(overlay); + } + private normalizeByMeta(meta: { key?: string; type: string }, raw: string): string { if (meta.type === 'boolean') { return raw === 'true' || raw === '1' ? 'true' : 'false'; diff --git a/server/dukang-api/src/integrations/sms/sms.mock.provider.ts b/server/dukang-api/src/integrations/sms/sms.mock.provider.ts index 3fb8852..b1255ae 100644 --- a/server/dukang-api/src/integrations/sms/sms.mock.provider.ts +++ b/server/dukang-api/src/integrations/sms/sms.mock.provider.ts @@ -1,8 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; +import { resolveMockSmsFixedCode } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { MockSmsCodeService } from '../../common/mock-sms-code/mock-sms-code.service'; import type { ISmsProvider, SmsActorRef, SmsSendResult } from './sms.interface'; -import { MOCK_SMS_FIXED_CODE, SmsCodeStore } from './sms-code.store'; +import { SmsCodeStore } from './sms-code.store'; function maskPhone(phone: string) { return `${phone.slice(0, 3)}****${phone.slice(-4)}`; @@ -19,7 +20,8 @@ export class SmsMockProvider implements ISmsProvider { ) {} async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise { - const code = await this.smsCodeStore.storeCode(phone, scene, MOCK_SMS_FIXED_CODE); + const fixedCode = resolveMockSmsFixedCode(process.env); + const code = await this.smsCodeStore.storeCode(phone, scene, fixedCode); await this.mockSmsCodeService.record(phone, scene, code); const masked = maskPhone(phone); this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`); diff --git a/server/dukang-api/src/modules/common/client-config.controller.ts b/server/dukang-api/src/modules/common/client-config.controller.ts index 8f65c25..9716bd5 100644 --- a/server/dukang-api/src/modules/common/client-config.controller.ts +++ b/server/dukang-api/src/modules/common/client-config.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get } from '@nestjs/common'; -import { parseMiniHomeBanners } from '@dukang/shared-types'; +import { parseMiniHomeBanners, resolveClientBrandRuntime } from '@dukang/shared-types'; import { SystemConfigService } from '../../common/system-config/system-config.service'; @Controller('common') @@ -12,6 +12,7 @@ export class ClientConfigController { const env = this.systemConfig.getMergedEnv(); const footer = (env.MINI_HOME_FOOTER_URL ?? '').trim(); const minClientVersion = (env.MINI_USER_MIN_VERSION ?? '').trim() || null; + const brand = resolveClientBrandRuntime(env); return { mockPay: cfg.mockPay, wechatPayEnabled: cfg.wechatPayEnabled, @@ -25,6 +26,12 @@ export class ClientConfigController { banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS), footerUrl: footer || null, }, + userH5Url: brand.userH5Url, + brandLogoUrl: brand.brandLogoUrl, + brandLogoWideUrl: brand.brandLogoWideUrl, + brandLogoMarkUrl: brand.brandLogoMarkUrl, + qualificationDisclosureUrl: brand.qualificationDisclosureUrl, + customerServicePhone: brand.customerServicePhone, }; } } diff --git a/杜康好客-v3-PRD.md b/杜康好客-v3-PRD.md index 4fcd3c1..1f47a55 100644 --- a/杜康好客-v3-PRD.md +++ b/杜康好客-v3-PRD.md @@ -19,6 +19,7 @@ | 3.4.11 | 08-04 | 开发计划 + 企微机器人/消息推送 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) | | 3.4.12 | 08-04 | 退款回滚/财务/批量任务·工单/套餐 imageUrl | [`v3.4.12`](./杜康好客-v3.4.12-工单迭代开发文档.md) | | 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) | +| 3.4.14 | 08-06 | mini-user 门头/套餐详情;**微信小程序配置可配 Logo·客服·H5·Mock码** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | --- diff --git a/杜康好客-v3-现状对照.md b/杜康好客-v3-现状对照.md index fe61800..d2541d9 100644 --- a/杜康好客-v3-现状对照.md +++ b/杜康好客-v3-现状对照.md @@ -54,13 +54,14 @@ | 3.4.10 | [`门店套餐`](./杜康好客-门店套餐功能开发文档-v3.4.10.md) | ✅ | | 3.4.11 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) | ✅ | | 3.4.12 | [`工单迭代`](./杜康好客-v3.4.12-工单迭代开发文档.md) | ✅ | -| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 `0181af0` | +| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 | +| 3.4.14 | [`mini-user 门店体验 + 小程序可配置`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | 🔶 开发中 | ## 5. 变更记录 | 日期 | 说明 | |------|------| -| 2026-08-06 | 文档压缩;现状对照更新 | +| 2026-08-06 | v3.4.14 mini-user 门头/套餐详情(开发中) | | 2026-08-05 | v3.4.13 | | 2026-08-04 | v3.4.11 / v3.4.12 | | 2026-07-11 | 首版对照表 | diff --git a/杜康好客-v3.4.14-mini-user门店体验开发文档.md b/杜康好客-v3.4.14-mini-user门店体验开发文档.md new file mode 100644 index 0000000..9165363 --- /dev/null +++ b/杜康好客-v3.4.14-mini-user门店体验开发文档.md @@ -0,0 +1,51 @@ +# 杜康好客 · v3.4.14 mini-user 门店体验 + 小程序可配置项 + +> **2026-08-06** · **开发中** · PRD §0.6 · mini-user `3.4.14` · **未发版** + +## 范围 + +| 项 | 交付 | +|----|------| +| 门头照 | **固定 4:3 区域**;`aspectFit` 缩放完整显示(不裁剪);标题信息卡固定接在门头下方 | +| 门店详情·套餐 | 仅完整标题纵向列表;点击进入详情 | +| 套餐详情页 | 实底导航让出胶囊区;内容区顶/左右留白;有图在门店名下;无图直接菜品 | +| **系统设置·微信小程序** | Logo / 资质图 / 客服电话 / C 端 H5 / Mock 验证码 **可配置**,经 `client-config` 下发 | + +## 页面路由 + +| 路径 | 参数 | +|------|------| +| `pages/store-detail/index` | `id` 门店 ID | +| `pages/store-package-detail/index` | `storeId` · `index` 套餐序号(0-based) | + +数据:复用 `GET /stores/:id` 内嵌 `packages[]`,详情页按 index 取项。 + +## 系统设置(微信小程序配置) + +HQ → 系统设置 → **微信小程序配置**。启动时空缺键用 shared-types 默认值补种。 + +| 配置键 | 说明 | +|--------|------| +| `USER_H5_URL` | C 端 H5 落地页(推广码等) | +| `BRAND_LOGO_OSS_BASE` | Logo OSS 根路径(说明用) | +| `BRAND_LOGO_URL` / `_WIDE_` / `_MARK_` | 方形 / 长方形 / 图标 Logo | +| `MINI_USER_STATIC_OSS_BASE` | 小程序静态资源根路径 | +| `QUALIFICATION_DISCLOSURE_URL` | 资质公示长图 | +| `CUSTOMER_SERVICE_PHONE` | 总部客服电话 | +| `MOCK_SMS_FIXED_CODE` | Mock 短信固定验证码(仅 MOCK_SMS 开启) | + +**下发**:`GET /common/client-config` 增加 `userH5Url`、`brandLogoUrl`、`brandLogoWideUrl`、`brandLogoMarkUrl`、`qualificationDisclosureUrl`、`customerServicePhone`。 +`MOCK_SMS_FIXED_CODE` 仅服务端 Mock 短信读取,不下发客户端。 + +## ACC + +- [ ] 门头区高度固定(4:3);图片 aspectFit 完整缩放;标题 section 位置不随图高变化 +- [ ] 门店详情套餐区仅标题列表,标题完整展示、可换行 +- [ ] 点击套餐进入详情页,字段完整;返回回到门店详情 +- [ ] 无套餐时不展示区块;index 非法时友好提示 +- [ ] HQ 微信小程序配置可改 Logo/电话/落地页/Mock 码;保存后 client-config 立即生效(无需重启) +- [ ] mini-user 登录/我的/客服/分享图读取配置;未配置时回退代码默认常量 + +## HQ 开发计划 + +创建版本 `v3.4.14`(`IN_PROGRESS`),关联本迭代任务。发版前再合并发布。