diff --git a/apps/admin-web/src/admin.css b/apps/admin-web/src/admin.css index 2143e93..e2089d8 100644 --- a/apps/admin-web/src/admin.css +++ b/apps/admin-web/src/admin.css @@ -300,7 +300,7 @@ body:has(.big-screen-page), .big-screen-list-head, .big-screen-row { display: grid; - grid-template-columns: 180px 1fr 160px 200px; + grid-template-columns: 208px 1fr 160px 200px; align-items: center; column-gap: 16px; } @@ -434,6 +434,13 @@ body:has(.big-screen-page), font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; + letter-spacing: 0.5px; + min-width: 0; +} + +.big-screen-amount--6d { + font-size: 0.86em; + letter-spacing: 0; } .big-screen-items { @@ -540,13 +547,14 @@ body:has(.big-screen-page), .big-screen-fx-card { position: relative; z-index: 2; - min-width: 420px; - max-width: 72vw; + min-width: min(420px, 92vw); + max-width: min(920px, 92vw); padding: 28px 40px 32px; border-radius: 12px; text-align: center; background: rgba(8, 22, 48, 0.82); backdrop-filter: blur(8px); + box-sizing: border-box; } .big-screen-fx-card--t1 { @@ -562,7 +570,7 @@ body:has(.big-screen-page), } .big-screen-fx-card--t3 { - min-width: 520px; + min-width: min(560px, 94vw); padding: 36px 48px 40px; border: 3px solid #ffd666; box-shadow: @@ -623,12 +631,19 @@ body:has(.big-screen-page), } .big-screen-fx-amount { - font-size: 64px; + font-size: clamp(44px, 8.5vw, 64px); font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: 2px; line-height: 1.1; color: #e6f7ff; + max-width: 100%; + margin-inline: auto; +} + +.big-screen-fx-amount--6d { + font-size: clamp(36px, 7vw, 52px) !important; + letter-spacing: 1px; } .big-screen-fx-card--t1 .big-screen-fx-amount { @@ -643,7 +658,7 @@ body:has(.big-screen-page), } .big-screen-fx-card--t3 .big-screen-fx-amount { - font-size: 84px; + font-size: clamp(52px, 10vw, 84px); color: #ffd666; text-shadow: 0 0 12px #ffd666, @@ -651,6 +666,11 @@ body:has(.big-screen-page), animation: big-screen-amount-flash 0.9s ease-in-out infinite; } +.big-screen-fx-card--t3 .big-screen-fx-amount--6d { + font-size: clamp(44px, 8.5vw, 68px) !important; + letter-spacing: 1px; +} + @keyframes big-screen-amount-pop { 0% { transform: scale(0.7); @@ -695,14 +715,23 @@ body:has(.big-screen-page), } .big-screen-list-head, .big-screen-row { - grid-template-columns: 140px 1fr 120px 160px; + grid-template-columns: 172px 1fr 120px 160px; font-size: 16px; } + .big-screen-amount--6d { + font-size: 0.82em; + } .big-screen-fx-amount { - font-size: 44px; + font-size: clamp(36px, 8vw, 44px); } .big-screen-fx-card--t3 .big-screen-fx-amount { - font-size: 56px; + font-size: clamp(40px, 9vw, 56px); + } + .big-screen-fx-amount--6d { + font-size: clamp(32px, 6.5vw, 40px) !important; + } + .big-screen-fx-card--t3 .big-screen-fx-amount--6d { + font-size: clamp(34px, 7vw, 48px) !important; } } diff --git a/apps/admin-web/src/components/ProxyOrderModal.tsx b/apps/admin-web/src/components/ProxyOrderModal.tsx index 19d53a6..9c387f0 100644 --- a/apps/admin-web/src/components/ProxyOrderModal.tsx +++ b/apps/admin-web/src/components/ProxyOrderModal.tsx @@ -217,6 +217,12 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder method: 'POST', body: JSON.stringify({ payMethod: 'NATIVE' }), }); + if (pay.mode === 'mock') { + message.success(`支付成功:${order.orderNo}`); + resetForm(); + onSuccess({ id: order.id, orderNo: order.orderNo }); + return; + } setCodeUrl(pay.codeUrl ?? null); startPoll(order.id); } catch (e) { diff --git a/apps/admin-web/src/lib/admin-events.ts b/apps/admin-web/src/lib/admin-events.ts index 818a9ce..abf8da7 100644 --- a/apps/admin-web/src/lib/admin-events.ts +++ b/apps/admin-web/src/lib/admin-events.ts @@ -1,71 +1,5 @@ export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed'; export const AUDIT_NOTICE_CHANGED_EVENT = 'dukang:audit-notice-changed'; -export const BIG_SCREEN_DEMO_CHANNEL = 'dukang-big-screen'; -export const BIG_SCREEN_DEMO_STORAGE_KEY = 'dukang:big-screen-demo'; - -export type BigScreenDemoOrder = { - payAmount: number; - items: string; - userPhoneMasked: string; -}; - -export type BigScreenDemoPayload = { - type: 'demo'; - id: string; - at: number; - orders: BigScreenDemoOrder[]; -}; - -export function buildBigScreenDemoPayload(): BigScreenDemoPayload { - return { - type: 'demo', - id: `demo-${Date.now()}`, - at: Date.now(), - orders: [ - { payAmount: 1288, items: '杜康君酿 · 浓香型 × 6瓶', userPhoneMasked: '138****8888' }, - { payAmount: 688, items: '杜康君酿 · 浓香型 × 2瓶', userPhoneMasked: '139****6666' }, - { payAmount: 188, items: '杜康君酿 · 浓香型 × 1瓶', userPhoneMasked: '137****1888' }, - ], - }; -} - -export function triggerBigScreenDemo() { - const payload = buildBigScreenDemoPayload(); - try { - localStorage.setItem(BIG_SCREEN_DEMO_STORAGE_KEY, JSON.stringify(payload)); - } catch { - /* ignore quota */ - } - try { - const ch = new BroadcastChannel(BIG_SCREEN_DEMO_CHANNEL); - ch.postMessage(payload); - ch.close(); - } catch { - /* BroadcastChannel 不可用时靠 localStorage 冷启动 */ - } - return payload; -} - -export function readPendingBigScreenDemo(maxAgeMs = 8000): BigScreenDemoPayload | null { - try { - const raw = localStorage.getItem(BIG_SCREEN_DEMO_STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as BigScreenDemoPayload; - if (parsed?.type !== 'demo' || !parsed.id || !Array.isArray(parsed.orders)) return null; - if (Date.now() - Number(parsed.at || 0) > maxAgeMs) return null; - return parsed; - } catch { - return null; - } -} - -export function clearPendingBigScreenDemo() { - try { - localStorage.removeItem(BIG_SCREEN_DEMO_STORAGE_KEY); - } catch { - /* ignore */ - } -} export function notifyPackageAuditChanged() { window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT)); diff --git a/apps/admin-web/src/pages/BigScreenPage.tsx b/apps/admin-web/src/pages/BigScreenPage.tsx index 0ba45f7..84a3b43 100644 --- a/apps/admin-web/src/pages/BigScreenPage.tsx +++ b/apps/admin-web/src/pages/BigScreenPage.tsx @@ -1,18 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { request } from '../lib/api'; -import { - BIG_SCREEN_DEMO_CHANNEL, - clearPendingBigScreenDemo, - readPendingBigScreenDemo, - type BigScreenDemoPayload, -} from '../lib/admin-events'; export type BigScreenOrder = { id: string; orderNo: string; payAmount: number; items: string; + /** 展示用时间(付款成功时间) */ createdAt: string; + paidAt?: string; userPhoneMasked: string | null; }; @@ -23,6 +19,101 @@ const ROW_MS = 2500; const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; const TIER_DURATION: Record = { 1: 3000, 2: 4500, 3: 6000 }; +const SEED_IDS = new Set( + ['seed-1', 'seed-2', 'seed-3', 'seed-4', 'seed-5', 'seed-6', 'seed-7', 'seed-8'], +); + +function seedPaidAt(h: number, m: number, s: number): string { + const d = new Date(); + d.setHours(h, m, s, 0); + return d.toISOString(); +} + +/** 发布会滚动列表垫底演示数据(不参与成交动效) */ +const BIG_SCREEN_SEED_ORDERS: BigScreenOrder[] = [ + { + id: 'seed-1', + orderNo: 'DK-DEMO-001', + payAmount: 1288, + items: '杜康君酿 · 浓香型 × 6瓶', + createdAt: seedPaidAt(14, 28, 36), + paidAt: seedPaidAt(14, 28, 36), + userPhoneMasked: '138****8888', + }, + { + id: 'seed-2', + orderNo: 'DK-DEMO-002', + payAmount: 688, + items: '杜康君酿 · 浓香型 × 2瓶', + createdAt: seedPaidAt(14, 22, 18), + paidAt: seedPaidAt(14, 22, 18), + userPhoneMasked: '139****6666', + }, + { + id: 'seed-3', + orderNo: 'DK-DEMO-003', + payAmount: 1588, + items: '杜康典藏 · 礼盒装 × 2套', + createdAt: seedPaidAt(14, 15, 42), + paidAt: seedPaidAt(14, 15, 42), + userPhoneMasked: '136****9527', + }, + { + id: 'seed-4', + orderNo: 'DK-DEMO-004', + payAmount: 520, + items: '杜康特曲 · 500ml × 4瓶', + createdAt: seedPaidAt(14, 8, 55), + paidAt: seedPaidAt(14, 8, 55), + userPhoneMasked: '135****3210', + }, + { + id: 'seed-5', + orderNo: 'DK-DEMO-005', + payAmount: 268, + items: '杜康君酿 · 浓香型 × 1瓶', + createdAt: seedPaidAt(13, 56, 7), + paidAt: seedPaidAt(13, 56, 7), + userPhoneMasked: '137****1888', + }, + { + id: 'seed-6', + orderNo: 'DK-DEMO-006', + payAmount: 999, + items: '杜康窖藏 · 珍藏版 × 3瓶', + createdAt: seedPaidAt(13, 48, 33), + paidAt: seedPaidAt(13, 48, 33), + userPhoneMasked: '133****7788', + }, + { + id: 'seed-7', + orderNo: 'DK-DEMO-007', + payAmount: 188, + items: '杜康小酒 · 品鉴装 × 2瓶', + createdAt: seedPaidAt(13, 35, 21), + paidAt: seedPaidAt(13, 35, 21), + userPhoneMasked: '158****0099', + }, + { + id: 'seed-8', + orderNo: 'DK-DEMO-008', + payAmount: 1200, + items: '杜康君酿 · 浓香型 × 5瓶', + createdAt: seedPaidAt(13, 22, 49), + paidAt: seedPaidAt(13, 22, 49), + userPhoneMasked: '186****5566', + }, +]; + +function mergeDisplayItems(live: BigScreenOrder[]): BigScreenOrder[] { + if (!live.length) return [...BIG_SCREEN_SEED_ORDERS]; + return [...live, ...BIG_SCREEN_SEED_ORDERS]; +} + +function isSeedOrder(order: BigScreenOrder): boolean { + return SEED_IDS.has(order.id); +} + function pad(n: number) { return String(n).padStart(2, '0'); } @@ -35,7 +126,7 @@ function formatDateLine(d: Date) { return `${d.getFullYear()} / ${pad(d.getMonth() + 1)} / ${pad(d.getDate())} ${WEEKDAYS[d.getDay()]}`; } -function formatHm(iso: string): string { +function formatOrderTime(iso: string): string { try { return formatClock(new Date(iso)); } catch { @@ -50,6 +141,11 @@ function formatAmount(n: number): string { }); } +function amountCssClass(payAmount: number, base: string): string { + const wide = Math.floor(Math.abs(Number(payAmount || 0))) >= 100_000; + return wide ? `${base} ${base}--6d` : base; +} + export function amountTier(payAmount: number): AmountTier { if (payAmount >= 1000) return 3; if (payAmount >= 500) return 2; @@ -83,9 +179,11 @@ function OrderRow({ className={`big-screen-row big-screen-row--t${tier}${latest ? ' is-latest' : ''}`} > {latest ? : null} - ¥ {formatAmount(order.payAmount)} + + ¥ {formatAmount(order.payAmount)} + {order.items || '—'} - {formatHm(order.createdAt)} + {formatOrderTime(order.paidAt ?? order.createdAt)} {order.userPhoneMasked || '—'} ); @@ -253,11 +351,13 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v {tier >= 2 ?
: null}
{tier === 3 ? '高额成交' : tier === 2 ? '大额成交' : '新成交'}
-
¥ {formatAmount(displayAmount)}
+
+ ¥ {formatAmount(displayAmount)} +
{order.items || '—'}
{order.userPhoneMasked || '—'} - {formatHm(order.createdAt)} + {formatOrderTime(order.paidAt ?? order.createdAt)}
@@ -270,12 +370,16 @@ export default function BigScreenPage() { const [paused, setPaused] = useState(false); const [celebrate, setCelebrate] = useState(null); const seenIdsRef = useRef | null>(null); + const celebratedIdsRef = useRef>(new Set()); const queueRef = useRef([]); const celebratingRef = useRef(false); const viewportRef = useRef(null); - const demoSeenRef = useRef>(new Set()); const [viewportH, setViewportH] = useState(0); + useEffect(() => { + for (const o of BIG_SCREEN_SEED_ORDERS) celebratedIdsRef.current.add(o.id); + }, []); + const playNext = useCallback(() => { const next = queueRef.current.shift() ?? null; celebratingRef.current = !!next; @@ -285,11 +389,13 @@ export default function BigScreenPage() { const enqueueNew = useCallback( (fresh: BigScreenOrder[]) => { - if (!fresh.length) return; - const ranked = [...fresh].sort((a, b) => { + const toCelebrate = fresh.filter((o) => !celebratedIdsRef.current.has(o.id)); + if (!toCelebrate.length) return; + for (const o of toCelebrate) celebratedIdsRef.current.add(o.id); + const ranked = [...toCelebrate].sort((a, b) => { const td = amountTier(b.payAmount) - amountTier(a.payAmount); if (td !== 0) return td; - return +new Date(b.createdAt) - +new Date(a.createdAt); + return +new Date(b.paidAt ?? b.createdAt) - +new Date(a.paidAt ?? a.createdAt); }); queueRef.current.push(...ranked); if (!celebratingRef.current) playNext(); @@ -297,38 +403,16 @@ export default function BigScreenPage() { [playNext], ); - const playDemo = useCallback( - (payload: BigScreenDemoPayload) => { - if (!payload?.orders?.length || demoSeenRef.current.has(payload.id)) return; - demoSeenRef.current.add(payload.id); - const now = new Date().toISOString(); - const fake: BigScreenOrder[] = payload.orders.map((o, i) => ({ - id: `${payload.id}-${i}`, - orderNo: `${payload.id}-${i}`, - payAmount: o.payAmount, - items: o.items, - createdAt: now, - userPhoneMasked: o.userPhoneMasked, - })); - setItems((prev) => [...fake, ...prev.filter((row) => !row.id.startsWith('demo-'))]); - enqueueNew(fake); - clearPendingBigScreenDemo(); - }, - [enqueueNew], - ); - const fetchData = useCallback(() => { return request<{ items: BigScreenOrder[] }>('/admin/orders/big-screen?limit=2000') .then((d) => { const list = d.items ?? []; - setItems((prev) => { - const demos = prev.filter((o) => o.id.startsWith('demo-')); - return demos.length ? [...demos, ...list] : list; - }); + setItems(mergeDisplayItems(list)); setLoadError(''); const seen = seenIdsRef.current; if (!seen) { seenIdsRef.current = new Set(list.map((o) => o.id)); + for (const o of list) celebratedIdsRef.current.add(o.id); return; } const fresh = list.filter((o) => !seen.has(o.id)); @@ -337,6 +421,7 @@ export default function BigScreenPage() { }) .catch((e) => { setLoadError(e instanceof Error ? e.message : '加载失败'); + setItems(mergeDisplayItems([])); }); }, [enqueueNew]); @@ -346,24 +431,6 @@ export default function BigScreenPage() { return () => clearInterval(id); }, [fetchData]); - useEffect(() => { - const pending = readPendingBigScreenDemo(); - if (pending) playDemo(pending); - - let ch: BroadcastChannel | null = null; - try { - ch = new BroadcastChannel(BIG_SCREEN_DEMO_CHANNEL); - ch.onmessage = (ev: MessageEvent) => { - if (ev.data?.type === 'demo' && ev.data.orders?.length) playDemo(ev.data); - }; - } catch { - ch = null; - } - return () => { - ch?.close(); - }; - }, [playDemo]); - useEffect(() => { const html = document.documentElement; const prevHtml = html.style.overflow; @@ -399,7 +466,8 @@ export default function BigScreenPage() { const trackItems = useMemo(() => unitItems.concat(unitItems), [unitItems]); const marqueeMs = Math.max(unitItems.length, 1) * ROW_MS; const rolling = items.length > 0 && unitItems.length > 0; - const latestId = items[0]?.id; + const latestLive = items.find((o) => !isSeedOrder(o)); + const latestId = latestLive?.id; return (
@@ -431,7 +499,7 @@ export default function BigScreenPage() {
下单金额 下单商品 - 下单时间 + 付款时间 下单人
diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index 7dfbf82..3282636 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -21,7 +21,6 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api'; -import { triggerBigScreenDemo } from '../lib/admin-events'; import { ADMIN_OPTIONS_PAGE_SIZE, DELIVERY_PROVIDER_LABELS, @@ -192,7 +191,6 @@ export default function OrdersPage() { const [trackOpen, setTrackOpen] = useState(false); const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete'); const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders'); - const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; const selectedOrders = useMemo( () => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), @@ -545,17 +543,6 @@ export default function OrdersPage() { 订单监控 - {isSuperAdmin ? ( - - ) : null} {canProxyOrder ? ( -
- -
- - - -
- - -
- - ); -} diff --git a/apps/h5-user/src/components/PhoneVerifySheet.tsx b/apps/h5-user/src/components/PhoneVerifySheet.tsx deleted file mode 100644 index ed6ad19..0000000 --- a/apps/h5-user/src/components/PhoneVerifySheet.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { useEffect, useState } from 'react'; -import type { WechatLoginResult } from '@dukang/shared-types'; -import { SmsScene } from '@dukang/shared-types'; -import { bindPhone, request, type SessionPayload } from '../lib/api'; -import { normalizePhoneInput, validateMobilePhone } from '../lib/phone'; -import { useSmsCode } from '../lib/use-sms-code'; -import { useUserSession } from '../contexts/UserSessionContext'; - -type PhoneVerifySheetProps = { - open: boolean; - /** 打开时预填手机号(如收货地址中的手机号) */ - defaultPhone?: string; - mode?: 'bind_phone' | 'wechat_bind_phone'; - wxSessionKey?: string; - title?: string; - description?: string; - onClose: () => void; - onSuccess: () => void; -}; - -export default function PhoneVerifySheet({ - open, - defaultPhone, - mode = 'bind_phone', - wxSessionKey, - title, - description, - onClose, - onSuccess, -}: PhoneVerifySheetProps) { - const { applySession } = useUserSession(); - const [phone, setPhone] = useState(''); - const [code, setCode] = useState(''); - const [loading, setLoading] = useState(false); - const { sendCode, sending, codeCooldown, sentHint, error, setError, clearMessages } = useSmsCode(); - - useEffect(() => { - if (!open) { - setPhone(''); - setCode(''); - setError(''); - clearMessages(); - return; - } - if (defaultPhone) { - const normalized = normalizePhoneInput(defaultPhone); - if (validateMobilePhone(normalized).ok) { - setPhone(normalized); - } - } - }, [open, defaultPhone, clearMessages, setError]); - - async function onSendCode() { - clearMessages(); - await sendCode(phone, SmsScene.BIND_PHONE); - } - - async function submit() { - const phoneCheck = validateMobilePhone(phone); - if (!phoneCheck.ok) { - setError(phoneCheck.message ?? '请输入正确的手机号码'); - return; - } - if (!code.trim()) { - setError('请输入验证码'); - return; - } - setLoading(true); - setError(''); - try { - if (mode === 'wechat_bind_phone') { - if (!wxSessionKey) { - setError('微信会话已过期,请重新授权'); - return; - } - const data = await request('USER_H5', '/auth/wechat/bind-phone', { - method: 'POST', - body: JSON.stringify({ wxSessionKey, phone, code }), - }); - if (data.accessToken) { - applySession({ - accessToken: data.accessToken, - refreshToken: data.refreshToken ?? '', - deviceKey: data.deviceKey, - phoneVerified: !!data.phoneVerified, - user: data.user as SessionPayload['user'], - }); - } - } else { - const session = await bindPhone(phone, code); - applySession(session as SessionPayload); - } - onSuccess(); - onClose(); - } catch (e) { - setError(e instanceof Error ? e.message : '验证失败'); - } finally { - setLoading(false); - } - } - - if (!open) return null; - - const sheetTitle = title ?? (mode === 'wechat_bind_phone' ? '绑定手机号' : '验证手机号'); - const sheetDesc = - description ?? - (mode === 'wechat_bind_phone' - ? '建议绑定手机号,便于订单通知与售后;关闭可跳过继续支付' - : '建议绑定手机号,便于订单通知与售后;关闭可跳过继续下单'); - - return ( -
- -
- {(error || sentHint) && ( -

{error || sentHint}

- )} - - - - - ); -} diff --git a/apps/h5-user/src/components/ProductCarousel.tsx b/apps/h5-user/src/components/ProductCarousel.tsx deleted file mode 100644 index 35f49e5..0000000 --- a/apps/h5-user/src/components/ProductCarousel.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useRef, useState } from 'react'; -import AppImage from '@dukang/shared-ui/AppImage'; - -type Props = { - images: string[]; - alt: string; - variant?: 'home' | 'detail' | 'store'; -}; - -export default function ProductCarousel({ images, alt, variant = 'home' }: Props) { - const scrollRef = useRef(null); - const [activeIndex, setActiveIndex] = useState(0); - const slides = images.length > 0 ? images : ['']; - - function onScroll() { - const el = scrollRef.current; - if (!el || el.offsetWidth === 0) return; - setActiveIndex(Math.round(el.scrollLeft / el.offsetWidth)); - } - - const wrapClass = - variant === 'store' - ? 'store-detail-carousel-wrap' - : variant === 'detail' - ? 'detail-carousel-wrap' - : 'home-carousel-wrap'; - const trackClass = - variant === 'store' - ? 'store-detail-carousel' - : variant === 'detail' - ? 'detail-carousel' - : 'home-carousel'; - const dotClass = - variant === 'store' - ? 'store-detail-carousel-dot' - : variant === 'detail' - ? 'detail-carousel-dot' - : 'home-carousel-dot'; - const itemClass = - variant === 'store' - ? 'store-detail-carousel-item' - : variant === 'detail' - ? 'detail-carousel-item' - : 'home-carousel-item'; - const placeholderClass = - variant === 'store' - ? 'store-detail-carousel-placeholder' - : variant === 'detail' - ? 'detail-carousel-placeholder' - : 'home-carousel-placeholder'; - - return ( -
-
- {slides.map((src, i) => ( -
- {src ? ( - - ) : ( -
- )} -
- ))} -
- {slides.length > 1 && ( -
- {slides.map((_, i) => ( - - ))} -
- )} -
- ); -} diff --git a/apps/h5-user/src/components/RegionPicker.tsx b/apps/h5-user/src/components/RegionPicker.tsx deleted file mode 100644 index d47ac48..0000000 --- a/apps/h5-user/src/components/RegionPicker.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { - REGION_ALL, - getCities, - getCitiesForPicker, - getDistricts, - getDistrictsForPicker, - getProvincesForPicker, - normalizeRegionSelection, - toCityLevelRegion, - type RegionSelection, -} from '../lib/region-data'; - -type RegionPickerProps = { - open: boolean; - value: RegionSelection; - onClose: () => void; - onConfirm: (region: RegionSelection) => void; - /** 2 = 仅省/市(门店列表);3 = 省/市/区(地址等) */ - levels?: 2 | 3; -}; - -type PickerLevel = 'province' | 'city' | 'district'; - -const ALL_TABS: Array<{ key: PickerLevel; label: string }> = [ - { key: 'province', label: '省份' }, - { key: 'city', label: '城市' }, - { key: 'district', label: '区县' }, -]; - -function initialTab(value: RegionSelection, levels: 2 | 3): PickerLevel { - const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value); - if (levels === 2) { - return normalized.province && normalized.province !== REGION_ALL ? 'city' : 'province'; - } - if (normalized.district && normalized.district !== REGION_ALL) return 'district'; - if (normalized.city && normalized.city !== REGION_ALL) return 'city'; - return 'province'; -} - -function tabLabel(tab: PickerLevel, draft: RegionSelection, fallback: string) { - if (tab === 'province') { - return draft.province && draft.province !== REGION_ALL ? draft.province : fallback; - } - if (tab === 'city') { - return draft.city && draft.city !== REGION_ALL ? draft.city : fallback; - } - return draft.district && draft.district !== REGION_ALL ? draft.district : fallback; -} - -export default function RegionPicker({ - open, - value, - onClose, - onConfirm, - levels = 3, -}: RegionPickerProps) { - const [draft, setDraft] = useState(value); - const [activeTab, setActiveTab] = useState('province'); - const listRef = useRef(null); - - const tabs = levels === 2 ? ALL_TABS.slice(0, 2) : ALL_TABS; - - useEffect(() => { - if (!open) return; - const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value); - setDraft(normalized); - setActiveTab(initialTab(value, levels)); - }, [open, value, levels]); - - const listItems = useMemo(() => { - if (activeTab === 'province') return getProvincesForPicker(); - if (activeTab === 'city') return getCitiesForPicker(draft.province); - return getDistrictsForPicker(draft.province, draft.city); - }, [activeTab, draft.province, draft.city]); - - const selectedValue = - activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district; - - const canConfirm = - levels === 2 - ? Boolean(draft.province && draft.city) - : Boolean(draft.province && draft.city && draft.district); - - useEffect(() => { - if (!open) return; - scrollActiveIntoView(listRef.current, selectedValue); - }, [open, activeTab, selectedValue, listItems.length]); - - if (!open) return null; - - function scrollActiveIntoView(container: HTMLDivElement | null, label: string) { - if (!container || !label) return; - const active = container.querySelector(`[data-label="${CSS.escape(label)}"]`); - active?.scrollIntoView({ block: 'nearest' }); - } - - function selectProvince(province: string) { - if (province === REGION_ALL) { - setDraft({ province: REGION_ALL, city: REGION_ALL, district: REGION_ALL }); - setActiveTab('city'); - return; - } - const nextCities = getCities(province); - const city = nextCities[0] ?? ''; - if (levels === 2) { - setDraft({ - province, - city, - district: REGION_ALL, - }); - setActiveTab('city'); - return; - } - const nextDistricts = getDistricts(province, city); - setDraft({ - province, - city, - district: nextDistricts[0] ?? '', - }); - setActiveTab('city'); - } - - function selectCity(city: string) { - if (city === REGION_ALL) { - setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL }); - if (levels === 3) setActiveTab('district'); - return; - } - if (levels === 2) { - setDraft({ - ...draft, - city, - district: REGION_ALL, - }); - return; - } - const nextDistricts = getDistricts(draft.province, city); - setDraft({ - ...draft, - city, - district: nextDistricts[0] ?? '', - }); - setActiveTab('district'); - } - - function selectDistrict(district: string) { - setDraft({ ...draft, district }); - } - - function onSelectItem(item: string) { - if (activeTab === 'province') selectProvince(item); - else if (activeTab === 'city') selectCity(item); - else selectDistrict(item); - } - - function onTabClick(tab: PickerLevel) { - if (tab === 'city' && !draft.province) return; - if (tab === 'district' && (!draft.province || !draft.city)) return; - setActiveTab(tab); - } - - function handleConfirm() { - if (!canConfirm) return; - const next = levels === 2 ? toCityLevelRegion(draft) : normalizeRegionSelection(draft); - onConfirm(next); - } - - return ( -
-
e.stopPropagation()} - > -
-
- {tabs.map((tab) => { - const disabled = - (tab.key === 'city' && !draft.province) || - (tab.key === 'district' && (!draft.province || !draft.city)); - return ( - - ); - })} -
- -
- -
- {listItems.map((item) => ( - - ))} -
-
-
- ); -} diff --git a/apps/h5-user/src/components/SubPageHeader.tsx b/apps/h5-user/src/components/SubPageHeader.tsx deleted file mode 100644 index ddad7b0..0000000 --- a/apps/h5-user/src/components/SubPageHeader.tsx +++ /dev/null @@ -1,14 +0,0 @@ -type SubPageHeaderProps = { - title: string; - onBack: () => void; -}; - -export default function SubPageHeader({ title, onBack }: SubPageHeaderProps) { - return ( -
- -
- ); -} diff --git a/apps/h5-user/src/components/TabMainHeader.tsx b/apps/h5-user/src/components/TabMainHeader.tsx deleted file mode 100644 index 5120a7e..0000000 --- a/apps/h5-user/src/components/TabMainHeader.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { ReactNode } from 'react'; - -type TabMainHeaderProps = { - title: string; - extra?: ReactNode; - className?: string; -}; - -export default function TabMainHeader({ title, extra, className = '' }: TabMainHeaderProps) { - // H5:系统标题已展示;无右侧内容时整栏不渲染,避免顶部留白 - if (!extra) { - return null; - } - - return ( -
- {extra ?
{extra}
: null} -
- ); -} diff --git a/apps/h5-user/src/components/WechatShareBootstrap.tsx b/apps/h5-user/src/components/WechatShareBootstrap.tsx deleted file mode 100644 index d49165c..0000000 --- a/apps/h5-user/src/components/WechatShareBootstrap.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useEffect } from 'react'; -import { useLocation } from 'react-router-dom'; -import { applyDefaultWechatShare } from '../lib/wechat-share'; - -/** 路由变化时刷新微信右上角分享卡片 */ -export default function WechatShareBootstrap() { - const location = useLocation(); - - useEffect(() => { - void applyDefaultWechatShare().catch(() => {}); - }, [location.pathname, location.search]); - - return null; -} diff --git a/apps/h5-user/src/contexts/UserSessionContext.tsx b/apps/h5-user/src/contexts/UserSessionContext.tsx deleted file mode 100644 index ad91f8e..0000000 --- a/apps/h5-user/src/contexts/UserSessionContext.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, - type ReactNode, -} from 'react'; -import { - bootstrapSession, - clearAuth, - ensureSession, - getDeviceKey, - request, - saveSession, - type SessionPayload, - type UserProfile, -} from '../lib/api'; -import { touchPromoIfNeeded } from '../lib/promo'; - -type UserSessionContextValue = { - ready: boolean; - profile: UserProfile | null; - phoneVerified: boolean; - applySession: (session: SessionPayload) => void; - refreshProfile: () => Promise; - resetSession: () => Promise; -}; - -const UserSessionContext = createContext(null); - -export function UserSessionProvider({ children }: { children: ReactNode }) { - const [ready, setReady] = useState(false); - const [profile, setProfile] = useState(null); - const [phoneVerified, setPhoneVerified] = useState(false); - - const applySession = useCallback((session: SessionPayload) => { - saveSession(session); - if (session.user) setProfile(session.user); - setPhoneVerified(!!session.phoneVerified || !!session.user?.phoneVerified); - }, []); - - const refreshProfile = useCallback(async () => { - const me = await request('USER_H5', '/auth/me'); - setProfile(me); - setPhoneVerified(!!me.phoneVerified); - }, []); - - const resetSession = useCallback(async () => { - clearAuth(); - const session = await bootstrapSession(); - applySession(session); - }, [applySession]); - - useEffect(() => { - let cancelled = false; - (async () => { - try { - const session = await ensureSession(); - if (cancelled) return; - applySession(session); - if (!session.user) { - await refreshProfile(); - } - await touchPromoIfNeeded(); - } catch { - if (!cancelled) { - try { - const session = await bootstrapSession(); - applySession(session); - await refreshProfile(); - } catch { - /* ignore */ - } - } - } finally { - if (!cancelled) setReady(true); - } - })(); - return () => { - cancelled = true; - }; - }, [applySession, refreshProfile]); - - const value = useMemo( - () => ({ - ready, - profile, - phoneVerified, - applySession, - refreshProfile, - resetSession, - }), - [ready, profile, phoneVerified, applySession, refreshProfile, resetSession], - ); - - if (!ready) { - return ( -
-

加载中...

-
- ); - } - - return {children}; -} - -export function useUserSession() { - const ctx = useContext(UserSessionContext); - if (!ctx) throw new Error('useUserSession must be used within UserSessionProvider'); - return ctx; -} - -export function useOptionalUserSession() { - return useContext(UserSessionContext); -} - -/** @deprecated use profile from useUserSession */ -export { getDeviceKey }; diff --git a/apps/h5-user/src/layouts/TabLayout.tsx b/apps/h5-user/src/layouts/TabLayout.tsx deleted file mode 100644 index 86a08b4..0000000 --- a/apps/h5-user/src/layouts/TabLayout.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { NavLink, Outlet } from 'react-router-dom'; - -const TABS = [ - { to: '/', end: true, icon: 'home', label: '首页', fillActive: false }, - { to: '/stores', icon: 'storefront', label: '门店', fillActive: true }, - { to: '/benefit', icon: 'card_giftcard', label: '好客权益', fillActive: false }, - { to: '/mine', icon: 'person', label: '我的', fillActive: true }, -] as const; - -export default function TabLayout() { - return ( - <> - - - - ); -} diff --git a/apps/h5-user/src/lib/analytics.ts b/apps/h5-user/src/lib/analytics.ts deleted file mode 100644 index 06915a3..0000000 --- a/apps/h5-user/src/lib/analytics.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createUserTracker, getSessionId } from '@dukang/client-logging'; -import { apiBase } from './api'; - -const tracker = createUserTracker({ - apiBase, - clientApp: 'USER_H5', -}); - -export { getSessionId }; - -export function track(eventName: string, params?: Record) { - tracker.track(eventName, params); -} - -export function trackPageView(eventName: string, params?: Record) { - tracker.trackPageView(eventName, params); -} - -export function initUserAnalytics() { - tracker.trackSessionStart(); -} diff --git a/apps/h5-user/src/lib/api.ts b/apps/h5-user/src/lib/api.ts deleted file mode 100644 index e83b58e..0000000 --- a/apps/h5-user/src/lib/api.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { reportApiError } from '@dukang/client-logging'; - -export const BRAND = { - red: '#A02D30', - yellow: '#FFC107', - bg: '#f5f5f5', - text: '#333', - muted: '#999', -}; - -export const apiBase = '/api/v1'; -const CLIENT_APP = 'USER_H5'; - -export type UserProfile = { - id: string; - userNo: string; - phone: string | null; - phoneVerified: boolean; - nickname: string | null; - avatarUrl: string | null; - hasWechat: boolean; -}; - -export type SessionPayload = { - accessToken: string; - refreshToken: string; - deviceKey?: string; - phoneVerified: boolean; - user?: UserProfile; -}; - -const DEVICE_KEY = 'deviceKey'; -const ACCESS_TOKEN = 'accessToken'; -const REFRESH_TOKEN = 'refreshToken'; - -export function getDeviceKey() { - return localStorage.getItem(DEVICE_KEY); -} - -export function saveSession(data: SessionPayload) { - localStorage.setItem(ACCESS_TOKEN, data.accessToken); - localStorage.setItem(REFRESH_TOKEN, data.refreshToken); - if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey); -} - -export function saveAuth(data: { accessToken: string; refreshToken?: string; deviceKey?: string }) { - localStorage.setItem(ACCESS_TOKEN, data.accessToken); - if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken); - if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey); -} - -export function clearAuth() { - localStorage.removeItem(ACCESS_TOKEN); - localStorage.removeItem(REFRESH_TOKEN); -} - -export function isLoggedIn() { - return !!localStorage.getItem(ACCESS_TOKEN); -} - -const AUTH_RECOVERY_EXEMPT_PATHS = ['/auth/session/bootstrap', '/auth/token/refresh']; - -async function recoverSession(): Promise { - const refreshed = await refreshSession(); - if (refreshed) return refreshed; - clearAuth(); - return bootstrapSession(); -} - -async function rawRequest( - path: string, - options: RequestInit = {}, - token?: string | null, -): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - 'X-Client-App': CLIENT_APP, - ...(options.headers as Record), - }; - const authToken = token ?? localStorage.getItem(ACCESS_TOKEN); - if (authToken) headers.Authorization = `Bearer ${authToken}`; - - const res = await fetch(`${apiBase}${path}`, { ...options, headers }); - const json = await res.json(); - if (json.code !== 0) { - const err = new Error(json.message || '请求失败') as Error & { status?: number }; - err.status = json.code; - if (json.code === 400) { - reportApiError( - { apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) }, - { message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' }, - ); - } - throw err; - } - return json.data as T; -} - -async function requestWithAuthRetry( - path: string, - options: RequestInit = {}, - retried = false, -): Promise { - try { - return await rawRequest(path, options); - } catch (e) { - const err = e as Error & { status?: number }; - const canRecover = - err.status === 401 && - !retried && - !AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p)); - if (!canRecover) throw e; - await recoverSession(); - return requestWithAuthRetry(path, options, true); - } -} - -export async function request( - _clientApp: string, - path: string, - options: RequestInit = {}, -): Promise { - if (!isLoggedIn() && !AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p))) { - await bootstrapSession(); - } - return requestWithAuthRetry(path, options); -} - -export async function bootstrapSession(): Promise { - const deviceKey = getDeviceKey(); - const data = await rawRequest( - '/auth/session/bootstrap', - { - method: 'POST', - body: JSON.stringify(deviceKey ? { deviceKey } : {}), - }, - null, - ); - saveSession(data); - return data; -} - -export async function refreshSession(): Promise { - const refreshToken = localStorage.getItem(REFRESH_TOKEN); - if (!refreshToken) return null; - try { - const data = await rawRequest( - '/auth/token/refresh', - { - method: 'POST', - body: JSON.stringify({ refreshToken }), - }, - null, - ); - saveSession(data); - return data; - } catch { - return null; - } -} - -export async function ensureSession(): Promise { - if (isLoggedIn()) { - try { - const me = await rawRequest('/auth/me'); - return { - accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '', - refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '', - deviceKey: getDeviceKey() ?? undefined, - phoneVerified: !!me.phoneVerified, - user: me, - }; - } catch (e) { - const err = e as Error & { status?: number }; - if (err.status === 401) { - clearAuth(); - } else { - const refreshed = await refreshSession(); - if (refreshed) return refreshed; - } - } - } - return bootstrapSession(); -} - -export async function bindPhone(phone: string, code: string): Promise { - if (!isLoggedIn()) { - await bootstrapSession(); - } - const data = await requestWithAuthRetry('/auth/phone/bind', { - method: 'POST', - body: JSON.stringify({ phone, code }), - }); - saveSession(data); - return data; -} diff --git a/apps/h5-user/src/lib/client-location.ts b/apps/h5-user/src/lib/client-location.ts deleted file mode 100644 index 58df920..0000000 --- a/apps/h5-user/src/lib/client-location.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { getWechatLocation } from '@dukang/weixin-sdk'; -import { weixinSdk } from './weixin'; - -export type ClientGpsLocation = { - province?: string; - city?: string; - district?: string; - latitude: number; - longitude: number; - address?: string; -}; - -/** 尝试获取客户端 GPS(微信优先,其次 H5 Geolocation),失败返回 null 不阻塞下单 */ -export async function tryGetClientGpsLocation(): Promise { - const loc = await weixinSdk.getLocation(); - if (!loc) return null; - return { - latitude: loc.latitude, - longitude: loc.longitude, - }; -} - -/** @deprecated 使用 tryGetClientGpsLocation */ -export { getWechatLocation }; diff --git a/apps/h5-user/src/lib/customer-service.ts b/apps/h5-user/src/lib/customer-service.ts deleted file mode 100644 index 7aa6e50..0000000 --- a/apps/h5-user/src/lib/customer-service.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 非微信环境已提示 - */ -export function openWecomCustomerService(): boolean { - if (!isWechatEnv()) { - window.alert('请在微信中打开以联系在线客服'); - return false; - } - window.location.href = getCustomerServiceWecomUrl(); - return true; -} - -/** @deprecated 请用 getCustomerServicePhone(),保留兼容旧引用 */ -export { CUSTOMER_SERVICE_PHONE }; diff --git a/apps/h5-user/src/lib/navigation.ts b/apps/h5-user/src/lib/navigation.ts deleted file mode 100644 index d98af2c..0000000 --- a/apps/h5-user/src/lib/navigation.ts +++ /dev/null @@ -1,62 +0,0 @@ -export type CheckoutContext = { - productId?: string | null; - qty?: string | number | null; - addressId?: string | null; - cross?: boolean | string | null; - select?: boolean | string | null; -}; - -export function readCheckoutContext(params: URLSearchParams): CheckoutContext { - return { - productId: params.get('productId'), - qty: params.get('qty'), - addressId: params.get('addressId'), - cross: params.get('cross'), - select: params.get('select'), - }; -} - -export function appendCheckoutContext(qs: URLSearchParams, ctx: CheckoutContext) { - if (ctx.productId) qs.set('productId', ctx.productId); - if (ctx.qty != null && ctx.qty !== '') qs.set('qty', String(ctx.qty)); - if (ctx.addressId) qs.set('addressId', ctx.addressId); - if (ctx.cross === true || ctx.cross === '1') qs.set('cross', '1'); - if (ctx.select === true || ctx.select === '1') qs.set('select', '1'); -} - -export function buildOrderConfirmUrl(search: CheckoutContext) { - const qs = new URLSearchParams(); - if (search.productId) qs.set('productId', search.productId); - if (search.qty != null && search.qty !== '') qs.set('qty', String(search.qty)); - if (search.addressId) qs.set('addressId', search.addressId); - if (search.cross === true || search.cross === '1') qs.set('cross', '1'); - const query = qs.toString(); - return query ? `/order/confirm?${query}` : '/order/confirm'; -} - -export function buildAddressListUrl(ctx: CheckoutContext = {}) { - const qs = new URLSearchParams(); - appendCheckoutContext(qs, ctx); - const query = qs.toString(); - return query ? `/addresses?${query}` : '/addresses'; -} - -export function buildAddressEditUrl(id: string | 'new', ctx: CheckoutContext = {}) { - const path = id === 'new' ? '/addresses/new' : `/addresses/${id}/edit`; - const qs = new URLSearchParams(); - appendCheckoutContext(qs, ctx); - const query = qs.toString(); - return query ? `${path}?${query}` : path; -} - -export function buildProductDetailUrl(productId?: string | null) { - return productId ? `/product/${productId}` : '/'; -} - -export function buildOrderAddressSelectUrl(orderId: string) { - return `/addresses?orderId=${orderId}&select=1`; -} - -export function hasCheckoutContext(ctx: CheckoutContext) { - return Boolean(ctx.productId || ctx.select === true || ctx.select === '1'); -} diff --git a/apps/h5-user/src/lib/order-images.ts b/apps/h5-user/src/lib/order-images.ts deleted file mode 100644 index 822f4cb..0000000 --- a/apps/h5-user/src/lib/order-images.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** Stitch 确认订单页商品缩略图 */ -export const STITCH_ORDER_PRODUCT_IMAGE = - 'https://lh3.googleusercontent.com/aida-public/AB6AXuAfqy5X1jKiMBB-L5amwR3xfLYbFBc_qsPbB9mdQZxWlV3rrOARPFVhLRDlW7r8Ig03O6c_ZJKLcVEsgYCblwKg8FZ4-EWwcc5bMNc3UsmBycu3bZ5E6S_aH9UBv0_nEP0sMD8rJsC_rMYBiGDMvRbd52taX-Ir_sfRiVvQu7ImFV-YvU54iXE2x51naVuR8qxwmK7YKitPClg0Pysga859a2-yiJ_ID0QR5xM2o84QbMwNyOEoDDTKSDNqG6J9jfeTsiIYb5vdVmc'; diff --git a/apps/h5-user/src/lib/pay-wechat.ts b/apps/h5-user/src/lib/pay-wechat.ts deleted file mode 100644 index 85a5bba..0000000 --- a/apps/h5-user/src/lib/pay-wechat.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types'; -import { isWxAuthorizeEnabled } from '@dukang/shared-types'; -import { toAppPath } from '@dukang/weixin-sdk'; -import { isWechatEnv, weixinSdk } from './weixin'; -import { request, saveSession, type UserProfile } from './api'; - -const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED'; - -export function isWechatAuthRequiredError(err: unknown): boolean { - return err instanceof Error && err.message === WECHAT_AUTH_REQUIRED; -} - -export async function fetchClientConfig(): Promise { - return request('USER_H5', '/common/client-config'); -} - -export async function fetchUserProfile(): Promise { - return request('USER_H5', '/auth/me'); -} - -/** 真实微信支付且未绑定微信时需要授权 */ -export function needsWechatAuthForPay( - config: ClientRuntimeConfig, - profile: UserProfile | null, -): boolean { - if (!isWxAuthorizeEnabled(config)) return false; - return !config.mockPay && config.wechatPayEnabled && isWechatEnv() && !profile?.hasWechat; -} - -export function saveWechatLoginResult(result: WechatLoginResult): boolean { - if (!result.accessToken) return false; - saveSession({ - accessToken: result.accessToken, - refreshToken: result.refreshToken ?? '', - deviceKey: result.deviceKey, - phoneVerified: !!result.phoneVerified, - user: result.user as never, - }); - return true; -} - -export async function authorizeWechatForPay(): Promise { - const config = await fetchClientConfig(); - if (!isWxAuthorizeEnabled(config)) return; - if (!isWechatEnv()) { - throw new Error('请在微信内打开以完成授权'); - } - return weixinSdk.login(); -} - -export function buildLoginReturnUrl(pathname: string, search: string) { - return `${toAppPath('/login')}?return=${encodeURIComponent(`${pathname}${search}`)}`; -} diff --git a/apps/h5-user/src/lib/phone.ts b/apps/h5-user/src/lib/phone.ts deleted file mode 100644 index 0edbaf7..0000000 --- a/apps/h5-user/src/lib/phone.ts +++ /dev/null @@ -1,19 +0,0 @@ -const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/; - -export function normalizePhoneInput(value: string): string { - return value.replace(/\D/g, '').slice(0, 11); -} - -export function validateMobilePhone(phone: string): { ok: boolean; message?: string } { - const trimmed = phone.trim(); - if (!trimmed) { - return { ok: false, message: '请输入手机号码' }; - } - if (trimmed.length !== 11) { - return { ok: false, message: '手机号码须为 11 位' }; - } - if (!MOBILE_PHONE_RE.test(trimmed)) { - return { ok: false, message: '请输入正确的手机号码' }; - } - return { ok: true }; -} diff --git a/apps/h5-user/src/lib/product-images.ts b/apps/h5-user/src/lib/product-images.ts deleted file mode 100644 index 734cb91..0000000 --- a/apps/h5-user/src/lib/product-images.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** 商品无图时的占位图 */ -export const PRODUCT_IMAGE_FALLBACK = '/images/1.png'; - -export type ProductImageSource = { - mainImageUrl?: string | null; - carouselUrls?: string[] | null; - detailImageUrls?: string[] | null; -}; - -function uniqueUrls(urls: Array) { - const seen = new Set(); - const result: string[] = []; - for (const url of urls) { - if (!url || seen.has(url)) continue; - seen.add(url); - result.push(url); - } - return result; -} - -/** 首页/列表轮播图:优先 CAROUSEL,否则封面 */ -export function getProductImages(source?: ProductImageSource | null): string[] { - const carousel = uniqueUrls(source?.carouselUrls ?? []); - if (carousel.length > 0) return carousel; - - const main = source?.mainImageUrl; - if (main) return [main]; - - return [PRODUCT_IMAGE_FALLBACK]; -} - -/** 单张主图:封面优先 */ -export function getProductMainImage(source?: ProductImageSource | null): string { - return source?.mainImageUrl ?? source?.carouselUrls?.[0] ?? PRODUCT_IMAGE_FALLBACK; -} - -/** 详情页顶部轮播 */ -export function getProductCarouselImages(source?: ProductImageSource | null): string[] { - const carousel = uniqueUrls(source?.carouselUrls ?? []); - if (carousel.length > 0) return carousel; - return getProductImages(source); -} - -/** 详情页图文长图 */ -export function getProductDetailImages(source?: ProductImageSource | null): string[] { - return uniqueUrls(source?.detailImageUrls ?? []); -} diff --git a/apps/h5-user/src/lib/promo.ts b/apps/h5-user/src/lib/promo.ts deleted file mode 100644 index bbcdfd2..0000000 --- a/apps/h5-user/src/lib/promo.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { apiBase } from './api'; - -export const PROMO_STORAGE_KEY = 'dukang_promo_code'; -export const PROMO_PID_STORAGE_KEY = 'dukang_promo_pid'; - -function readPromoFromSearch(search: string): { code: string | null; pid: string | null } { - const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); - const code = params.get('promo')?.trim(); - const pid = params.get('pid')?.trim(); - return { - code: code ? code.toUpperCase() : null, - pid: pid || null, - }; -} - -/** 解析 URL 中的 ?promo= / ?pid= 并写入 sessionStorage */ -export function capturePromoFromUrl(): string | null { - if (typeof window === 'undefined') return null; - let parsed = readPromoFromSearch(window.location.search); - if (!parsed.code && !parsed.pid && window.location.hash.includes('?')) { - const hashQuery = window.location.hash.slice(window.location.hash.indexOf('?')); - parsed = readPromoFromSearch(hashQuery); - } - if (parsed.code) { - sessionStorage.setItem(PROMO_STORAGE_KEY, parsed.code); - } - if (parsed.pid) { - sessionStorage.setItem(PROMO_PID_STORAGE_KEY, parsed.pid); - } - return parsed.code ?? sessionStorage.getItem(PROMO_STORAGE_KEY); -} - -export function getStoredPromoCode(): string | null { - if (typeof window === 'undefined') return null; - return sessionStorage.getItem(PROMO_STORAGE_KEY); -} - -export function getStoredPromoPid(): string | null { - if (typeof window === 'undefined') return null; - return sessionStorage.getItem(PROMO_PID_STORAGE_KEY); -} - -/** 调用 /promo/touch 完成扫码归因(OptionalJwt:未登录也累加 scan_count) */ -export async function touchPromoIfNeeded(): Promise { - const promoCode = getStoredPromoCode(); - const qrcodeId = getStoredPromoPid(); - if (!promoCode && !qrcodeId) return; - - const headers: Record = { - 'Content-Type': 'application/json', - 'X-Client-App': 'USER_H5', - }; - const token = localStorage.getItem('accessToken'); - if (token) headers.Authorization = `Bearer ${token}`; - - try { - const res = await fetch(`${apiBase}/promo/touch`, { - method: 'POST', - headers, - body: JSON.stringify({ - ...(promoCode ? { promoCode } : {}), - ...(qrcodeId ? { qrcodeId } : {}), - }), - }); - const json = await res.json(); - if (json.code !== 0) return; - } catch { - /* 静默失败,不阻断用户流程 */ - } -} diff --git a/apps/h5-user/src/lib/region-data.ts b/apps/h5-user/src/lib/region-data.ts deleted file mode 100644 index 7e3959c..0000000 --- a/apps/h5-user/src/lib/region-data.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { regionData } from 'element-china-area-data'; - -export type RegionTree = Record>; - -/** 由国家标准省市区数据构建三级树 */ -function buildRegionTree(): RegionTree { - const tree: RegionTree = {}; - for (const province of regionData) { - const cities: Record = {}; - for (const city of province.children ?? []) { - cities[city.label] = (city.children ?? []).map((district) => district.label); - } - tree[province.label] = cities; - } - return tree; -} - -export const REGION_TREE: RegionTree = buildRegionTree(); - -export const PROVINCES = Object.keys(REGION_TREE); - -/** 三级选择「全市」选项(省/市/区列表首项) */ -export const REGION_ALL = '全市'; - -export function getCities(province: string): string[] { - if (province === REGION_ALL) return []; - return Object.keys(REGION_TREE[province] ?? {}); -} - -export function getDistricts(province: string, city: string): string[] { - if (province === REGION_ALL || city === REGION_ALL) return []; - return REGION_TREE[province]?.[city] ?? []; -} - -/** 省份列表(含全市) */ -export function getProvincesForPicker(): string[] { - return [...PROVINCES]; -} - -/** 城市列表(含全市) */ -export function getCitiesForPicker(province: string): string[] { - if (province === REGION_ALL) return [REGION_ALL]; - return [...getCities(province)]; -} - -/** 区县列表(含全市) */ -export function getDistrictsForPicker(province: string, city: string): string[] { - if (province === REGION_ALL || city === REGION_ALL) return [REGION_ALL]; - return [REGION_ALL, ...getDistricts(province, city)]; -} - -export function formatRegion(province: string, city: string, district: string): string { - if (!province) return ''; - if (province === REGION_ALL) return REGION_ALL; - if (city === REGION_ALL) return `${province} ${REGION_ALL}`; - if (district === REGION_ALL) return `${province} ${city} ${REGION_ALL}`; - if (!city || !district) return ''; - return `${province} ${city} ${district}`; -} - -/** 仅展示省、市两级(门店列表等场景) */ -export function formatRegionCity(province: string, city: string): string { - if (!province) return ''; - if (province === REGION_ALL) return REGION_ALL; - if (city === REGION_ALL) return `${province} ${REGION_ALL}`; - if (!city) return province; - return `${province} ${city}`; -} - -/** 门店筛选:固定为市级,不按区县过滤 */ -export function toCityLevelRegion(selection: RegionSelection): RegionSelection { - const normalized = normalizeRegionSelection(selection); - return { - province: normalized.province, - city: normalized.city, - district: REGION_ALL, - }; -} - -export type RegionSelection = { - province: string; - city: string; - district: string; -}; - -export const DEFAULT_REGION: RegionSelection = { - province: '河南省', - city: '郑州市', - district: '金水区', -}; - -export const FALLBACK_CITY_REGION: RegionSelection = { - province: '河南省', - city: '郑州市', - district: REGION_ALL, -}; - -export function regionFromGeo(province: string, city: string, district?: string): RegionSelection { - const cityName = city.endsWith('市') ? city : `${city}市`; - const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province; - const cities = getCities(provinceInTree); - const matchedCity = cities.includes(cityName) - ? cityName - : cities.find((c) => c.replace(/市$/, '') === city.replace(/市$/, '')) ?? cityName; - const districts = getDistricts(provinceInTree, matchedCity); - const districtName = - district && districts.includes(district) - ? district - : REGION_ALL; - return normalizeRegionSelection({ - province: provinceInTree, - city: cities.includes(matchedCity) ? matchedCity : matchedCity, - district: districtName, - }); -} - -/** 校验已选地区是否仍存在于数据源中 */ -export function normalizeRegionSelection(selection: RegionSelection): RegionSelection { - if (selection.province === REGION_ALL) { - return { province: REGION_ALL, city: REGION_ALL, district: REGION_ALL }; - } - - const province = PROVINCES.includes(selection.province) - ? selection.province - : DEFAULT_REGION.province; - - if (selection.city === REGION_ALL) { - return { province, city: REGION_ALL, district: REGION_ALL }; - } - - const cities = getCities(province); - const city = cities.includes(selection.city) ? selection.city : (cities[0] ?? DEFAULT_REGION.city); - - if (selection.district === REGION_ALL) { - return { province, city, district: REGION_ALL }; - } - - const districts = getDistricts(province, city); - const district = districts.includes(selection.district) - ? selection.district - : (districts[0] ?? DEFAULT_REGION.district); - - return { province, city, district }; -} diff --git a/apps/h5-user/src/lib/store-images.ts b/apps/h5-user/src/lib/store-images.ts deleted file mode 100644 index e0c3b22..0000000 --- a/apps/h5-user/src/lib/store-images.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** Stitch user/22 门店详情页 — 图集与地图占位 */ -export const STITCH_STORE_GALLERY = [ - 'https://lh3.googleusercontent.com/aida-public/AB6AXuDqN0DeYRsWNcXyfSRec8k2fhjJsqdji3-7zrtegkiEs5lwt3Sx4l79Uzmfys2pnl_gUY_m3Dpy5cAM8HW7JcR8qPtfO2G8YNcZ3x0DGSN1DUPJPq4emVhmIuwmaLEQ944UT9hjpNQsjdqieKV8R-X-2YvSOrsEa74kyfI5UNgRQaGdinhLw6co29ji3F9BRgfgWCQ1KqjotRBC4r9lzWBdeue-xryXvN8jEp_7hjwrBNOOZoIDPnKkpQQwLLpaa7Di6kfEfwzamCg', - 'https://lh3.googleusercontent.com/aida-public/AB6AXuAW3oxOc6XpywVJmpwYxIPBlP32ftPIOUB8JbYcqOAcLg1gbzKIgbDgBaPVUyH0gjdoWa7Hi0u1-NBYBwc5Jd3YpqufVWIou_ySFB2oLXA6T0u7DgUWKhtxbMnqMue-oasf8GlEy_e7-Rh41ZxVFkc30tQVAYz-Psm3CNgfRFqXoHDXCZAZz5ggOFOB2dScURBVN9qp_Ribo4DuE4LARgf19R8eKZR8mbDQdHesLaVl0icifdcQEb75QM6-VqCR3ch9BNKzLJ5hKMM', - 'https://lh3.googleusercontent.com/aida-public/AB6AXuDuv4URDejJ5j26kuBPG2fqmOmI90qQomZki-aHr3MmdF47Pq5HM7tiH68E77rrF0XjeaZjkQ0e39j5gY1-N_981-eguGZn8VIRZI0n6t-f8QVIhAyjL8kg-5ZD2yRsfgw5mnOYYPMyNUI54efLiU4M6mni6nJvTAXMvX0oBMXtTItj5U66d9BIvie7VfHoVYMelEW9ppZsSRzA7ZoIu6aRp_72OwAIcTFuiI2zccaAmfTk7dChjjHIHZ85B8dDc2G8Tym34SuJAZc', -] as const; - -export const STITCH_STORE_MAP = - 'https://lh3.googleusercontent.com/aida-public/AB6AXuByauC4oncButUsGa_t2ntIVz-iPk9zVUnYA6_P_URyFYzrWALFa2TKfdpEyrGs61N_sEjRYksO_HeKCZGJQXfRhXqf1iXrk8JPIfzDwb33bDacTr2J0HM-cSnNjcM1c5l6r_yuzsE0zuLBZpuAWVPwkOJUkdfk6xxNpABh-OQ0B6736YmxFQM-WJ5h0eLHpRB7RuyTFr5c_TwTysKyY6QVDZ-oJrx9Vc3FQE7Pc64o3bzP6qW3GEqdqm4WVIAMKiNKqUcXvkN8E48'; - -export function getStoreGalleryImages(coverUrl?: string | null, media?: Array<{ url: string }>) { - const fromMedia = (media || []).map((m) => m.url).filter(Boolean); - if (fromMedia.length > 0) return fromMedia; - if (coverUrl) return [coverUrl, ...STITCH_STORE_GALLERY.slice(1)]; - return [...STITCH_STORE_GALLERY]; -} diff --git a/apps/h5-user/src/lib/upload.ts b/apps/h5-user/src/lib/upload.ts deleted file mode 100644 index ba9f276..0000000 --- a/apps/h5-user/src/lib/upload.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { apiBase } from './api'; -import { - compressImageFileIfNeeded, - DEFAULT_OSS_MAX_UPLOAD_BYTES, - formatOssMaxSizeMb, -} from '@dukang/shared-ui/compressImage'; - -export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE'; - -export type UploadFileResult = { - url: string; - ossKey: string; - bucket: string; - mock: boolean; -}; - -/** 经 API 服务端转存 OSS */ -export async function uploadFileToOss( - file: File, - options: { bizType: string; mediaType?: OssMediaType }, -): Promise { - const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); - let prepared = file; - if (mediaType === 'IMAGE') { - prepared = await compressImageFileIfNeeded(file); - if (prepared.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) { - throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`); - } - } - const formData = new FormData(); - formData.append('file', prepared); - formData.append('bizType', options.bizType); - formData.append('mediaType', mediaType); - - const headers: Record = { - 'X-Client-App': 'USER_H5', - }; - const token = localStorage.getItem('accessToken'); - if (token) headers.Authorization = `Bearer ${token}`; - - const res = await fetch(`${apiBase}/common/resources/upload`, { - method: 'POST', - headers, - body: formData, - }); - const json = await res.json(); - if (json.code !== 0) throw new Error(json.message || '上传失败'); - return json.data as UploadFileResult; -} diff --git a/apps/h5-user/src/lib/use-sms-code.ts b/apps/h5-user/src/lib/use-sms-code.ts deleted file mode 100644 index d9c8600..0000000 --- a/apps/h5-user/src/lib/use-sms-code.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { SmsScene } from '@dukang/shared-types'; -import { request } from './api'; -import { fetchClientConfig } from './pay-wechat'; -import { validateMobilePhone } from './phone'; - -export function useSmsCode() { - const [codeCooldown, setCodeCooldown] = useState(0); - const [sending, setSending] = useState(false); - const [sentHint, setSentHint] = useState(''); - const [error, setError] = useState(''); - const [mockSms, setMockSms] = useState(true); - const timerRef = useRef | null>(null); - - useEffect(() => { - fetchClientConfig() - .then((cfg) => setMockSms(cfg.mockSms)) - .catch(() => {}); - return () => { - if (timerRef.current) clearInterval(timerRef.current); - }; - }, []); - - const startCooldown = useCallback(() => { - setCodeCooldown(60); - if (timerRef.current) clearInterval(timerRef.current); - timerRef.current = setInterval(() => { - setCodeCooldown((c) => { - if (c <= 1) { - if (timerRef.current) clearInterval(timerRef.current); - return 0; - } - return c - 1; - }); - }, 1000); - }, []); - - const sendCode = useCallback( - async (phone: string, scene: SmsScene) => { - const phoneCheck = validateMobilePhone(phone); - if (!phoneCheck.ok) { - setError(phoneCheck.message ?? '请输入正确的手机号码'); - return false; - } - setSending(true); - setError(''); - setSentHint(''); - try { - await request('USER_H5', '/auth/sms/send', { - method: 'POST', - body: JSON.stringify({ phone, scene }), - }); - setSentHint(mockSms ? '验证码已发送(开发模式)' : '验证码已发送,请注意查收'); - startCooldown(); - return true; - } catch (e) { - setError(e instanceof Error ? e.message : '发送失败'); - return false; - } finally { - setSending(false); - } - }, - [mockSms, startCooldown], - ); - - const clearMessages = useCallback(() => { - setError(''); - setSentHint(''); - }, []); - - return { - sendCode, - sending, - codeCooldown, - sentHint, - error, - setError, - clearMessages, - mockSms, - }; -} diff --git a/apps/h5-user/src/lib/usePageView.ts b/apps/h5-user/src/lib/usePageView.ts deleted file mode 100644 index d3e9fd7..0000000 --- a/apps/h5-user/src/lib/usePageView.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { trackPageView } from './analytics'; - -export function usePageView(eventName: string, params?: Record) { - const fired = useRef(false); - useEffect(() => { - if (fired.current) return; - fired.current = true; - trackPageView(eventName, params); - }, [eventName, params]); -} diff --git a/apps/h5-user/src/lib/wechat-auth.ts b/apps/h5-user/src/lib/wechat-auth.ts deleted file mode 100644 index ba5fb7b..0000000 --- a/apps/h5-user/src/lib/wechat-auth.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { WechatLoginResult } from '@dukang/shared-types'; -import { isWxAuthorizeEnabled } from '@dukang/shared-types'; -import { isWechatEnv, weixinSdk } from './weixin'; -import { - authorizeWechatForPay, - fetchClientConfig, - fetchUserProfile, - needsWechatAuthForPay, - saveWechatLoginResult, -} from './pay-wechat'; - -export type WechatAuthEnsureResult = - | { ok: true } - | { ok: false; redirecting: true } - | { ok: false; needBindPhone: true; wxSessionKey: string }; - -export async function checkNeedsWechatAuth(): Promise { - const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]); - return needsWechatAuthForPay(config, profile); -} - -/** 真实微信支付前确保已绑定微信;OAuth 跳转时返回 redirecting */ -export async function ensureWechatAuthForPay(): Promise { - if (!isWechatEnv()) return { ok: true }; - if (!(await checkNeedsWechatAuth())) return { ok: true }; - - const result = await authorizeWechatForPay(); - if (!result) return { ok: false, redirecting: true }; - - if (result.needBindPhone && result.wxSessionKey) { - return { ok: false, needBindPhone: true, wxSessionKey: result.wxSessionKey }; - } - - if (saveWechatLoginResult(result)) { - return { ok: true }; - } - return { ok: false, redirecting: true }; -} - -export async function handleWechatAuthCallback(): Promise { - if (!isWechatEnv()) return null; - const config = await fetchClientConfig(); - if (!isWxAuthorizeEnabled(config)) return null; - return weixinSdk.handleOAuthCallback(); -} - -export async function loginWithWechatSdk(): Promise { - const config = await fetchClientConfig(); - if (!isWxAuthorizeEnabled(config)) return; - if (!isWechatEnv()) { - throw new Error('请在微信内打开以使用微信一键授权'); - } - return weixinSdk.login(); -} - -export function applyWechatLoginResult(result: WechatLoginResult): boolean { - return saveWechatLoginResult(result); -} diff --git a/apps/h5-user/src/lib/wechat-location.ts b/apps/h5-user/src/lib/wechat-location.ts deleted file mode 100644 index d47f26e..0000000 --- a/apps/h5-user/src/lib/wechat-location.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { getWechatLocationDetailed } from '@dukang/weixin-sdk'; -import { apiBase } from './api'; -import { weixinSdk } from './weixin'; -import { regionFromGeo, type RegionSelection } from './region-data'; - -export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city'; -export const CITY_STORAGE_KEY = 'dukang_selected_city'; -export const FALLBACK_CITY_CODE = '410100'; - -export type ResolvedUserCity = { - province: string; - city: string; - district: string; - cityCode?: string; - cityName?: string; - openCity: boolean; - region: RegionSelection; - displayCity: string; -}; - -type GpsCityCache = ResolvedUserCity & { timestamp: number }; - -function readCache(): GpsCityCache | null { - try { - const raw = sessionStorage.getItem(GPS_CITY_STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as GpsCityCache; - if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null; - return parsed; - } catch { - return null; - } -} - -function writeCache(data: ResolvedUserCity) { - sessionStorage.setItem( - GPS_CITY_STORAGE_KEY, - JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache), - ); -} - -async function reportLocationToServer(payload: { - latitude?: number; - longitude?: number; - sdk: 'jssdk' | 'geolocation'; - status: 'success' | 'fail'; - errMsg?: string; -}) { - const token = localStorage.getItem('accessToken'); - const headers: Record = { - 'Content-Type': 'application/json', - 'X-Client-App': 'USER_H5', - }; - if (token) headers.Authorization = `Bearer ${token}`; - - const res = await fetch(`${apiBase}/common/wechat/location`, { - method: 'POST', - headers, - body: JSON.stringify(payload), - }); - const json = await res.json(); - if (json.code !== 0) { - throw new Error(json.message || '定位上报失败'); - } - return json.data as { - province?: string; - city?: string; - district?: string; - cityCode?: string; - cityName?: string; - openCity?: boolean; - }; -} - -function toResolved(data: { - province?: string; - city?: string; - district?: string; - cityCode?: string; - cityName?: string; - openCity?: boolean; -}): ResolvedUserCity | null { - if (!data.province || !data.city) return null; - const region = regionFromGeo(data.province, data.city, data.district); - const displayCity = data.cityName ?? (data.city.endsWith('市') ? data.city : `${data.city}市`); - return { - province: data.province, - city: data.city, - district: data.district ?? '', - cityCode: data.cityCode, - cityName: data.cityName, - openCity: !!data.openCity, - region, - displayCity, - }; -} - -/** 获取并解析用户当前城市(微信 JSSDK 优先),失败返回 null */ -export async function resolveUserCity(force = false): Promise { - if (!force) { - const cached = readCache(); - if (cached) return cached; - } - - const outcome = await getWechatLocationDetailed({ - apiBase, - clientApp: 'USER_H5', - getAccessToken: () => localStorage.getItem('accessToken'), - }); - - if (!outcome.location) { - await reportLocationToServer({ - sdk: outcome.sdk, - status: 'fail', - errMsg: outcome.errMsg, - }).catch(() => {}); - return null; - } - - try { - const data = await reportLocationToServer({ - latitude: outcome.location.latitude, - longitude: outcome.location.longitude, - sdk: outcome.sdk, - status: 'success', - }); - const resolved = toResolved(data); - if (resolved) { - writeCache(resolved); - if (resolved.openCity && resolved.cityCode) { - localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode); - } - } - return resolved; - } catch { - return null; - } -} - -export function syncCityCodeFromGps(resolved: ResolvedUserCity) { - if (resolved.openCity && resolved.cityCode) { - localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode); - } -} diff --git a/apps/h5-user/src/lib/wechat-share.ts b/apps/h5-user/src/lib/wechat-share.ts deleted file mode 100644 index 8ff447d..0000000 --- a/apps/h5-user/src/lib/wechat-share.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { WechatShareData } from '@dukang/weixin-sdk'; -import { getWechatShareLink, toAppPath } from '@dukang/weixin-sdk'; -import { isWechatEnv, weixinSdk } from './weixin'; - -export const DEFAULT_SHARE_TITLE = '你吃饭,杜康买单'; -export const DEFAULT_SHARE_DESC = '杜康好客 · 买酒享权益,全城门店可用'; -export const WECHAT_SHARE_HINT = '请点击右上角 ··· 分享给好友'; - -export function getDefaultShareImageUrl(): string { - if (typeof window === 'undefined') return toAppPath('/logo.png'); - return new URL(toAppPath('/logo.png'), window.location.origin).href; -} - -export function buildDefaultShareData( - overrides?: Partial, -): WechatShareData { - return { - title: overrides?.title ?? DEFAULT_SHARE_TITLE, - desc: overrides?.desc ?? DEFAULT_SHARE_DESC, - link: overrides?.link ?? getWechatShareLink(), - imgUrl: overrides?.imgUrl ?? getDefaultShareImageUrl(), - }; -} - -export async function applyDefaultWechatShare( - overrides?: Partial, -): Promise { - if (!isWechatEnv()) return; - await weixinSdk.setShare(buildDefaultShareData(overrides)); -} - -export function handleShareButtonClick(onHint: (message: string) => void): void { - const showHint = (message: string) => { - onHint(message); - if (message) { - window.setTimeout(() => onHint(''), 2500); - } - }; - - if (!isWechatEnv()) { - showHint('请在微信内打开后分享'); - return; - } - void applyDefaultWechatShare() - .then(() => showHint(WECHAT_SHARE_HINT)) - .catch(() => showHint('分享配置失败,请刷新页面后重试')); -} diff --git a/apps/h5-user/src/lib/weixin.ts b/apps/h5-user/src/lib/weixin.ts deleted file mode 100644 index f957657..0000000 --- a/apps/h5-user/src/lib/weixin.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk'; - -const CLIENT_APP = 'USER_H5'; - -export const weixinSdk = createWeixinSdk({ - apiBase: '/api/v1', - clientApp: CLIENT_APP, - getAccessToken: () => localStorage.getItem('accessToken'), -}); - -export { isWechatEnv }; diff --git a/apps/h5-user/src/main.tsx b/apps/h5-user/src/main.tsx deleted file mode 100644 index 53d311f..0000000 --- a/apps/h5-user/src/main.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; -import { getRouterBasename } from '@dukang/weixin-sdk'; -import { installClientErrorReporting } from '@dukang/client-logging'; -import App from './App'; -import { initUserAnalytics } from './lib/analytics'; -import { apiBase } from './lib/api'; -import './styles.css'; -import './styles/legal.css'; - -installClientErrorReporting({ apiBase, clientApp: 'USER_H5' }); -initUserAnalytics(); - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - , -); diff --git a/apps/h5-user/src/pages/AddressEditPage.tsx b/apps/h5-user/src/pages/AddressEditPage.tsx deleted file mode 100644 index 62e3ce3..0000000 --- a/apps/h5-user/src/pages/AddressEditPage.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import SubPageHeader from '../components/SubPageHeader'; -import RegionPicker from '../components/RegionPicker'; -import { request } from '../lib/api'; -import { buildAddressListUrl, readCheckoutContext } from '../lib/navigation'; -import { DEFAULT_REGION, formatRegion } from '../lib/region-data'; -import { normalizePhoneInput, validateMobilePhone } from '../lib/phone'; -import { usePageView } from '../lib/usePageView'; - -type AddressForm = { - receiverName: string; - phone: string; - province: string; - city: string; - district: string; - detail: string; - isDefault: boolean; -}; - -export default function AddressEditPage() { - const { id } = useParams(); - const [params] = useSearchParams(); - const isEdit = Boolean(id); - usePageView('address_edit', { mode: isEdit ? 'edit' : 'create' }); - const navigate = useNavigate(); - const [pickerOpen, setPickerOpen] = useState(false); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(''); - const [form, setForm] = useState({ - receiverName: '', - phone: '', - province: DEFAULT_REGION.province, - city: params.get('city') || DEFAULT_REGION.city, - district: DEFAULT_REGION.district, - detail: '', - isDefault: true, - }); - - const checkoutCtx = readCheckoutContext(params); - - function goBackToList() { - navigate(buildAddressListUrl(checkoutCtx)); - } - - useEffect(() => { - if (!id) return; - request>>('USER_H5', '/user/addresses').then((list) => { - const found = list.find((a) => String(a.id) === id); - if (found) { - setForm({ - receiverName: String(found.receiverName), - phone: String(found.phone), - province: String(found.province), - city: String(found.city), - district: String(found.district), - detail: String(found.detail), - isDefault: found.isDefault === 1, - }); - } - }); - }, [id]); - - const regionText = formatRegion(form.province, form.city, form.district); - - function validateForm(): string | null { - if (!form.receiverName.trim()) return '请输入收货人姓名'; - const phoneCheck = validateMobilePhone(form.phone); - if (!phoneCheck.ok) return phoneCheck.message ?? '请输入正确的手机号码'; - if (!form.province || !form.city || !form.district) return '请选择所在地区'; - if (!form.detail.trim()) return '请输入详细地址'; - return null; - } - - async function save() { - const validationError = validateForm(); - if (validationError) { - setError(validationError); - return; - } - - setSaving(true); - setError(''); - try { - if (isEdit && id) { - await request('USER_H5', `/user/addresses/${id}`, { - method: 'PUT', - body: JSON.stringify(form), - }); - } else { - await request('USER_H5', '/user/addresses', { - method: 'POST', - body: JSON.stringify(form), - }); - } - navigate(buildAddressListUrl(checkoutCtx)); - } catch (e) { - setError(e instanceof Error ? e.message : '保存失败'); - } finally { - setSaving(false); - } - } - - return ( -
- - -
-
-
- -
- { - setForm({ ...form, receiverName: e.target.value }); - setError(''); - }} - /> - person -
-
- -
- -
- +86 - { - setForm({ ...form, phone: normalizePhoneInput(e.target.value) }); - setError(''); - }} - /> - smartphone -
-
- - - -
- -
-