From 3f60c37265b04bd967bc738069d18cbff6f1c936 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Thu, 9 Jul 2026 17:14:00 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E5=88=86=E4=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/h5-user/src/App.tsx | 2 + .../src/components/WechatShareBootstrap.tsx | 14 ++++ apps/h5-user/src/lib/wechat-share.ts | 47 ++++++++++++ apps/h5-user/src/pages/OrderDetailPage.tsx | 7 +- apps/h5-user/src/pages/ProductDetailPage.tsx | 7 +- apps/h5-user/src/pages/StoreDetailPage.tsx | 7 +- packages/weixin-sdk/src/index.ts | 9 +++ packages/weixin-sdk/src/share.ts | 75 +++++++++++++++++++ packages/weixin-sdk/src/types.ts | 15 ++++ 9 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 apps/h5-user/src/components/WechatShareBootstrap.tsx create mode 100644 apps/h5-user/src/lib/wechat-share.ts create mode 100644 packages/weixin-sdk/src/share.ts diff --git a/apps/h5-user/src/App.tsx b/apps/h5-user/src/App.tsx index 53e0e19..3e051a4 100644 --- a/apps/h5-user/src/App.tsx +++ b/apps/h5-user/src/App.tsx @@ -21,6 +21,7 @@ import PayPage from './pages/PayPage'; import CustomerServicePage from './pages/CustomerServicePage'; import { UserSessionProvider } from './contexts/UserSessionContext'; import { capturePromoFromUrl, touchPromoIfNeeded } from './lib/promo'; +import WechatShareBootstrap from './components/WechatShareBootstrap'; function PromoBootstrap() { useEffect(() => { @@ -34,6 +35,7 @@ export default function App() { return ( + } /> }> diff --git a/apps/h5-user/src/components/WechatShareBootstrap.tsx b/apps/h5-user/src/components/WechatShareBootstrap.tsx new file mode 100644 index 0000000..d49165c --- /dev/null +++ b/apps/h5-user/src/components/WechatShareBootstrap.tsx @@ -0,0 +1,14 @@ +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/lib/wechat-share.ts b/apps/h5-user/src/lib/wechat-share.ts new file mode 100644 index 0000000..8ff447d --- /dev/null +++ b/apps/h5-user/src/lib/wechat-share.ts @@ -0,0 +1,47 @@ +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/pages/OrderDetailPage.tsx b/apps/h5-user/src/pages/OrderDetailPage.tsx index ddb79b5..f2c2b3f 100644 --- a/apps/h5-user/src/pages/OrderDetailPage.tsx +++ b/apps/h5-user/src/pages/OrderDetailPage.tsx @@ -1,9 +1,11 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import AppImage from '@dukang/shared-ui/AppImage'; +import AppToast from '../components/AppToast'; import { request } from '../lib/api'; import { buildOrderAddressSelectUrl } from '../lib/navigation'; import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images'; +import { handleShareButtonClick } from '../lib/wechat-share'; import ContactCustomerSheet from '../components/ContactCustomerSheet'; type OrderItem = { @@ -121,6 +123,7 @@ export default function OrderDetailPage() { const [order, setOrder] = useState(null); const [showCs, setShowCs] = useState(false); const [copyHint, setCopyHint] = useState(''); + const [shareToast, setShareToast] = useState(''); const [confirming, setConfirming] = useState(false); const isReship = order?.orderType === 'RESHIPMENT' || params.get('type') === 'reship'; @@ -206,7 +209,7 @@ export default function OrderDetailPage() {

杜康好客

-
+ +
diff --git a/apps/h5-user/src/pages/ProductDetailPage.tsx b/apps/h5-user/src/pages/ProductDetailPage.tsx index 4c5038e..0ceab31 100644 --- a/apps/h5-user/src/pages/ProductDetailPage.tsx +++ b/apps/h5-user/src/pages/ProductDetailPage.tsx @@ -3,8 +3,10 @@ import { Link, useNavigate, useParams } from 'react-router-dom'; import { useEffect, useState } from 'react'; import AppImage from '@dukang/shared-ui/AppImage'; import ProductCarousel from '../components/ProductCarousel'; +import AppToast from '../components/AppToast'; import { request } from '../lib/api'; import { track } from '../lib/analytics'; +import { handleShareButtonClick } from '../lib/wechat-share'; import { getProductCarouselImages, getProductDetailImages } from '../lib/product-images'; import type { ProductImageSource } from '../lib/product-images'; @@ -21,6 +23,7 @@ export default function ProductDetailPage() { const navigate = useNavigate(); const [product, setProduct] = useState(null); const [headerSolid, setHeaderSolid] = useState(false); + const [toast, setToast] = useState(''); useEffect(() => { if (id) request('USER_H5', `/catalog/products/${id}`).then(setProduct); @@ -66,12 +69,14 @@ export default function ProductDetailPage() { type="button" className="product-detail-header-btn" aria-label="分享" - onClick={() => {}} + onClick={() => handleShareButtonClick(setToast)} > share + +
diff --git a/apps/h5-user/src/pages/StoreDetailPage.tsx b/apps/h5-user/src/pages/StoreDetailPage.tsx index ac7b9ad..6e28ccb 100644 --- a/apps/h5-user/src/pages/StoreDetailPage.tsx +++ b/apps/h5-user/src/pages/StoreDetailPage.tsx @@ -2,8 +2,10 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import AppImage from '@dukang/shared-ui/AppImage'; import ProductCarousel from '../components/ProductCarousel'; +import AppToast from '../components/AppToast'; import { request } from '../lib/api'; import { track } from '../lib/analytics'; +import { handleShareButtonClick } from '../lib/wechat-share'; import { STITCH_STORE_MAP, getStoreGalleryImages } from '../lib/store-images'; type StoreMedia = { url: string; mediaType?: string; sortOrder?: number }; @@ -54,6 +56,7 @@ export default function StoreDetailPage() { const [store, setStore] = useState(null); const [benefitBalance, setBenefitBalance] = useState(0); const [headerSolid, setHeaderSolid] = useState(false); + const [toast, setToast] = useState(''); useEffect(() => { if (id) { @@ -114,11 +117,13 @@ export default function StoreDetailPage() { arrow_back

门店详情

- + +
diff --git a/packages/weixin-sdk/src/index.ts b/packages/weixin-sdk/src/index.ts index 47c8d7c..7f66347 100644 --- a/packages/weixin-sdk/src/index.ts +++ b/packages/weixin-sdk/src/index.ts @@ -21,6 +21,12 @@ export { scanQrCode } from './scan'; export { invokeWechatPay } from './pay'; export { chooseWechatImages, canUseWechatChooseImage } from './chooseImage'; export type { ChooseWechatImageOptions } from './chooseImage'; +export { + setWechatShareData, + canUseWechatShare, + getWechatShareLink, +} from './share'; +export type { WechatShareData } from './share'; export { getWechatOAuthUrl, startWechatOAuthLogin, @@ -41,6 +47,7 @@ import { getWechatLocation, getWechatLocationDetailed } from './location'; import { scanQrCode } from './scan'; import { invokeWechatPay } from './pay'; import { chooseWechatImages } from './chooseImage'; +import { setWechatShareData } from './share'; import { wechatLogin, handleWechatOAuthCallback, @@ -68,5 +75,7 @@ export function createWeixinSdk(config: WeixinSdkConfig) { clientApp: config.clientApp, getAccessToken: config.getAccessToken, }), + setShare: (data: Parameters[1]) => + setWechatShareData(config, data), }; } diff --git a/packages/weixin-sdk/src/share.ts b/packages/weixin-sdk/src/share.ts new file mode 100644 index 0000000..fb8a70e --- /dev/null +++ b/packages/weixin-sdk/src/share.ts @@ -0,0 +1,75 @@ +import { ensureJssdkReady, normalizeJssdkPageUrl } from './jssdk'; +import { getRuntimePlatform, isWechatBrowser } from './env'; +import type { WeixinSdkConfig } from './types'; + +export type WechatShareData = { + title: string; + desc: string; + link: string; + imgUrl: string; +}; + +export function canUseWechatShare(): boolean { + return isWechatBrowser() || getRuntimePlatform() === 'mini'; +} + +function applyShareData(data: WechatShareData): Promise { + return new Promise((resolve, reject) => { + const wx = window.wx; + if (!wx?.updateAppMessageShareData || !wx.updateTimelineShareData) { + reject(new Error('当前微信版本不支持分享,请升级微信后重试')); + return; + } + + let pending = 2; + let failed = false; + const done = (err?: Error) => { + if (err && !failed) { + failed = true; + reject(err); + return; + } + pending -= 1; + if (pending === 0 && !failed) resolve(); + }; + + wx.updateAppMessageShareData({ + title: data.title, + desc: data.desc, + link: data.link, + imgUrl: data.imgUrl, + success: () => done(), + fail: (res) => done(new Error(res.errMsg || '设置分享给朋友失败')), + }); + + wx.updateTimelineShareData({ + title: data.title, + link: data.link, + imgUrl: data.imgUrl, + success: () => done(), + fail: (res) => done(new Error(res.errMsg || '设置分享到朋友圈失败')), + }); + }); +} + +/** 配置微信内分享卡片(好友 / 朋友圈) */ +export async function setWechatShareData( + config: WeixinSdkConfig, + data: WechatShareData, +): Promise { + if (!canUseWechatShare()) return; + + await ensureJssdkReady({ + apiBase: config.apiBase ?? '/api/v1', + clientApp: config.clientApp, + getAccessToken: config.getAccessToken, + }); + + await applyShareData(data); +} + +/** 获取当前页分享链接(去掉 hash、OAuth 回跳参数) */ +export function getWechatShareLink(rawUrl?: string): string { + if (typeof window === 'undefined') return rawUrl ?? ''; + return normalizeJssdkPageUrl(rawUrl ?? window.location.href); +} diff --git a/packages/weixin-sdk/src/types.ts b/packages/weixin-sdk/src/types.ts index 5842c5a..43a2902 100644 --- a/packages/weixin-sdk/src/types.ts +++ b/packages/weixin-sdk/src/types.ts @@ -53,6 +53,21 @@ export type WxApi = { success?: (res: { localData: string }) => void; fail?: (res: { errMsg: string }) => void; }) => void; + updateAppMessageShareData: (options: { + title: string; + desc: string; + link: string; + imgUrl: string; + success?: () => void; + fail?: (res: { errMsg: string }) => void; + }) => void; + updateTimelineShareData: (options: { + title: string; + link: string; + imgUrl: string; + success?: () => void; + fail?: (res: { errMsg: string }) => void; + }) => void; }; export type MiniProgramWx = { From b506417f5e6eb738d9a8bb483d34a1eb0c75eefb Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Thu, 9 Jul 2026 17:26:24 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E5=90=88=E4=BC=99=E4=BA=BA=E7=AB=AF?= =?UTF-8?q?=E5=BC=80=E5=BA=97=E9=80=89=E7=85=A7=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/OssUploadField.tsx | 10 +- packages/weixin-sdk/src/chooseImage.ts | 112 ++++++++++++++---- packages/weixin-sdk/src/index.ts | 2 +- 3 files changed, 97 insertions(+), 27 deletions(-) diff --git a/apps/h5-partner/src/components/OssUploadField.tsx b/apps/h5-partner/src/components/OssUploadField.tsx index 1ac2406..12e8d74 100644 --- a/apps/h5-partner/src/components/OssUploadField.tsx +++ b/apps/h5-partner/src/components/OssUploadField.tsx @@ -7,6 +7,7 @@ import { needsWechatAuth, type PartnerProfile, } from '../lib/wechat-auth'; +import { formatChooseImageFailMessage } from '@dukang/weixin-sdk'; import { isWechatEnv, weixinSdk } from '../lib/weixin'; import type { ClientRuntimeConfig } from '@dukang/shared-types'; @@ -28,12 +29,11 @@ const DEFAULT_MAX_MB = 10; function formatWechatUploadError(e: unknown): string { const msg = e instanceof Error ? e.message : '无法打开相册'; + const formatted = formatChooseImageFailMessage(msg); + if (formatted) return formatted; if (/invalid signature/i.test(msg)) { return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试'; } - if (/permission|denied|拒绝/i.test(msg)) { - return '微信选图权限被拒绝,请在微信设置中允许相册/相机访问后重试'; - } return msg; } @@ -116,9 +116,10 @@ export default function OssUploadField({ } async function pickWechatImage() { + await weixinSdk.init(); const files = await weixinSdk.chooseImages({ count: 1, - sourceType: ['album', 'camera'], + sourceType: ['album'], }); if (files?.[0]) { await uploadSelectedFile(files[0]); @@ -143,7 +144,6 @@ export default function OssUploadField({ const msg = e instanceof Error ? e.message : '无法打开相册'; if (/cancel/i.test(msg)) return; setError(formatWechatUploadError(e)); - openNativeFilePicker(); } return; } diff --git a/packages/weixin-sdk/src/chooseImage.ts b/packages/weixin-sdk/src/chooseImage.ts index f360ad4..39734a5 100644 --- a/packages/weixin-sdk/src/chooseImage.ts +++ b/packages/weixin-sdk/src/chooseImage.ts @@ -1,5 +1,5 @@ -import { getRuntimePlatform } from './env'; -import { ensureJssdkReady, normalizeJssdkPageUrl, getJssdkSignUrl } from './jssdk'; +import { getRuntimePlatform, isIosDevice, isWechatDevTools } from './env'; +import { ensureJssdkReady, normalizeJssdkPageUrl } from './jssdk'; import type { WeixinSdkConfig } from './types'; export type ChooseWechatImageOptions = { @@ -7,6 +7,38 @@ export type ChooseWechatImageOptions = { sourceType?: Array<'album' | 'camera'>; }; +function delay(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +/** 将微信 chooseImage / getLocalImgData fail 的 errMsg 转为用户可读文案 */ +export function formatChooseImageFailMessage(errMsg: string): string { + const msg = errMsg.trim() || '无法打开相册'; + if (/cancel/i.test(msg)) return ''; + + if (/offline verifying|permission value is offline/i.test(msg)) { + return '微信权限验证中,请稍候再试或刷新页面后重新选择图片'; + } + + if (/invalid signature|config:fail|signature/i.test(msg)) { + return '微信 JSSDK 签名校验失败,请刷新页面后重试'; + } + + if (/photo.*denied|photos.*denied|相册.*权限|无法访问相册|无相册权限/i.test(msg)) { + return '相册权限未开启,请在 iPhone「设置 → 隐私与安全性 → 照片」中允许微信访问相册'; + } + + if (/system.*camera|camera.*not.*allowed|无法访问相机|无相机权限/i.test(msg)) { + return '相机权限未开启,请在 iPhone「设置 → 隐私与安全性 → 相机」中允许微信使用摄像头'; + } + + if (/permission|auth|denied|授权|拒绝/i.test(msg)) { + return `微信选图接口未就绪(${msg}),请刷新页面后重试`; + } + + return msg; +} + async function reportChooseImageEvent( config: WeixinSdkConfig, payload: { @@ -67,7 +99,34 @@ function localIdToFile(localId: string): Promise { reject(e instanceof Error ? e : new Error('图片解析失败')); } }, - fail: (err) => reject(new Error(err.errMsg || '读取图片失败')), + fail: (err) => { + const raw = err.errMsg || '读取图片失败'; + const formatted = formatChooseImageFailMessage(raw); + reject(new Error(formatted || raw)); + }, + }); + }); +} + +function invokeChooseImage( + count: number, + sourceType: Array<'album' | 'camera'>, +): Promise { + return new Promise((resolve, reject) => { + window.wx!.chooseImage!({ + count, + sizeType: ['compressed'], + sourceType, + success: (res) => resolve(res.localIds ?? []), + fail: (err) => { + const raw = err.errMsg || '无法打开相册'; + if (/cancel/i.test(raw)) { + resolve([]); + return; + } + const formatted = formatChooseImageFailMessage(raw); + reject(new Error(formatted || raw)); + }, }); }); } @@ -84,7 +143,6 @@ export async function chooseWechatImages( const sourceType = options.sourceType ?? ['album', 'camera']; if (platform === 'wechat-h5') { - const pageUrl = typeof window !== 'undefined' ? getJssdkSignUrl() : ''; const sourceTypeKey = sourceType.join(','); try { await ensureJssdkReady({ @@ -104,26 +162,38 @@ export async function chooseWechatImages( throw new Error(errMsg); } - let localIds: string[]; - try { - localIds = await new Promise((resolve, reject) => { - window.wx!.chooseImage!({ - count, - sizeType: ['compressed'], - sourceType, - success: (res) => resolve(res.localIds ?? []), - fail: (err) => reject(new Error(err.errMsg || '无法打开相册')), - }); - }); - } catch (e) { - const errMsg = e instanceof Error ? e.message : '无法打开相册'; - void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'choose' }); - throw e; + if (isIosDevice() && !isWechatDevTools()) { + await delay(500); } + let localIds: string[] = []; + const maxAttempts = 2; + let lastError: Error | null = null; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (attempt > 0) { + await delay(800); + } + try { + localIds = await invokeChooseImage(count, sourceType); + lastError = null; + break; + } catch (e) { + lastError = e instanceof Error ? e : new Error('无法打开相册'); + const raw = lastError.message; + const retryable = /offline verifying|权限验证中|接口未就绪/i.test(raw); + if (attempt < maxAttempts - 1 && retryable) continue; + void reportChooseImageEvent(config, { + status: 'fail', + errMsg: raw, + sourceType: sourceTypeKey, + stage: 'choose', + }); + throw lastError; + } + } + if (lastError) throw lastError; + if (!localIds.length) { - const errMsg = '未选择图片'; - void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'empty' }); return []; } diff --git a/packages/weixin-sdk/src/index.ts b/packages/weixin-sdk/src/index.ts index 7f66347..393730f 100644 --- a/packages/weixin-sdk/src/index.ts +++ b/packages/weixin-sdk/src/index.ts @@ -19,7 +19,7 @@ export { export type { WechatLocationOutcome } from './location'; export { scanQrCode } from './scan'; export { invokeWechatPay } from './pay'; -export { chooseWechatImages, canUseWechatChooseImage } from './chooseImage'; +export { chooseWechatImages, canUseWechatChooseImage, formatChooseImageFailMessage } from './chooseImage'; export type { ChooseWechatImageOptions } from './chooseImage'; export { setWechatShareData, From 5e96008ac46bba43247305a6d48b434d8eb3cf83 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Thu, 9 Jul 2026 21:12:28 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/h5-user/src/components/RegionPicker.tsx | 73 +++++++++++++++++--- apps/h5-user/src/lib/region-data.ts | 19 +++++ apps/h5-user/src/pages/StoreListPage.tsx | 17 +++-- 3 files changed, 90 insertions(+), 19 deletions(-) diff --git a/apps/h5-user/src/components/RegionPicker.tsx b/apps/h5-user/src/components/RegionPicker.tsx index c046a9c..d47ac48 100644 --- a/apps/h5-user/src/components/RegionPicker.tsx +++ b/apps/h5-user/src/components/RegionPicker.tsx @@ -7,6 +7,7 @@ import { getDistrictsForPicker, getProvincesForPicker, normalizeRegionSelection, + toCityLevelRegion, type RegionSelection, } from '../lib/region-data'; @@ -15,26 +16,57 @@ type RegionPickerProps = { value: RegionSelection; onClose: () => void; onConfirm: (region: RegionSelection) => void; + /** 2 = 仅省/市(门店列表);3 = 省/市/区(地址等) */ + levels?: 2 | 3; }; type PickerLevel = 'province' | 'city' | 'district'; -const TABS: Array<{ key: PickerLevel; label: string }> = [ +const ALL_TABS: Array<{ key: PickerLevel; label: string }> = [ { key: 'province', label: '省份' }, { key: 'city', label: '城市' }, { key: 'district', label: '区县' }, ]; -export default function RegionPicker({ open, value, onClose, onConfirm }: RegionPickerProps) { +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; - setDraft(normalizeRegionSelection(value)); - setActiveTab('province'); - }, [open, value]); + 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(); @@ -45,7 +77,10 @@ export default function RegionPicker({ open, value, onClose, onConfirm }: Region const selectedValue = activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district; - const canConfirm = Boolean(draft.province && draft.city && draft.district); + const canConfirm = + levels === 2 + ? Boolean(draft.province && draft.city) + : Boolean(draft.province && draft.city && draft.district); useEffect(() => { if (!open) return; @@ -68,6 +103,15 @@ export default function RegionPicker({ open, value, onClose, onConfirm }: Region } 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, @@ -80,7 +124,15 @@ export default function RegionPicker({ open, value, onClose, onConfirm }: Region function selectCity(city: string) { if (city === REGION_ALL) { setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL }); - setActiveTab('district'); + if (levels === 3) setActiveTab('district'); + return; + } + if (levels === 2) { + setDraft({ + ...draft, + city, + district: REGION_ALL, + }); return; } const nextDistricts = getDistricts(draft.province, city); @@ -110,7 +162,8 @@ export default function RegionPicker({ open, value, onClose, onConfirm }: Region function handleConfirm() { if (!canConfirm) return; - onConfirm(normalizeRegionSelection(draft)); + const next = levels === 2 ? toCityLevelRegion(draft) : normalizeRegionSelection(draft); + onConfirm(next); } return ( @@ -123,7 +176,7 @@ export default function RegionPicker({ open, value, onClose, onConfirm }: Region >
- {TABS.map((tab) => { + {tabs.map((tab) => { const disabled = (tab.key === 'city' && !draft.province) || (tab.key === 'district' && (!draft.province || !draft.city)); @@ -137,7 +190,7 @@ export default function RegionPicker({ open, value, onClose, onConfirm }: Region className={`region-picker-tab${activeTab === tab.key ? ' active' : ''}`} onClick={() => onTabClick(tab.key)} > - {tab.label} + {tabLabel(tab.key, draft, tab.label)} ); })} diff --git a/apps/h5-user/src/lib/region-data.ts b/apps/h5-user/src/lib/region-data.ts index 55dc928..7e3959c 100644 --- a/apps/h5-user/src/lib/region-data.ts +++ b/apps/h5-user/src/lib/region-data.ts @@ -58,6 +58,25 @@ export function formatRegion(province: string, city: string, district: string): 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; diff --git a/apps/h5-user/src/pages/StoreListPage.tsx b/apps/h5-user/src/pages/StoreListPage.tsx index 28627af..0775e33 100644 --- a/apps/h5-user/src/pages/StoreListPage.tsx +++ b/apps/h5-user/src/pages/StoreListPage.tsx @@ -8,8 +8,9 @@ import RegionPicker from '../components/RegionPicker'; import { DEFAULT_REGION, FALLBACK_CITY_REGION, - formatRegion, + formatRegionCity, REGION_ALL, + toCityLevelRegion, type RegionSelection, } from '../lib/region-data'; import { FALLBACK_CITY_CODE, resolveUserCity } from '../lib/wechat-location'; @@ -62,7 +63,7 @@ export default function StoreListPage() { const [loading, setLoading] = useState(false); const [categoryTab, setCategoryTab] = useState('全部'); const [keyword, setKeyword] = useState(''); - const [region, setRegion] = useState(DEFAULT_REGION); + const [region, setRegion] = useState(() => toCityLevelRegion(DEFAULT_REGION)); const [regionPickerOpen, setRegionPickerOpen] = useState(false); const [filterMode, setFilterMode] = useState<'auto' | 'manual'>('auto'); const [usedFallback, setUsedFallback] = useState(false); @@ -82,7 +83,7 @@ export default function StoreListPage() { if (filterMode !== 'auto' || geoReady) return; resolveUserCity().then((resolved) => { if (resolved) { - setRegion(resolved.region); + setRegion(toCityLevelRegion(resolved.region)); } setGeoReady(true); }); @@ -103,7 +104,7 @@ export default function StoreListPage() { code !== FALLBACK_CITY_CODE ) { setUsedFallback(true); - setRegion(FALLBACK_CITY_REGION); + setRegion(toCityLevelRegion(FALLBACK_CITY_REGION)); return null; } return data; @@ -138,11 +139,8 @@ export default function StoreListPage() { const city = s.cityName ?? '郑州市'; if (region.province !== REGION_ALL && province !== region.province) return false; if (region.city !== REGION_ALL && city !== region.city) return false; - if (region.district !== REGION_ALL && s.district !== region.district) return false; return true; }); - } else if (region.district !== REGION_ALL) { - list = list.filter((s) => s.district === region.district); } const q = keyword.trim().toLowerCase(); if (q) { @@ -156,7 +154,7 @@ export default function StoreListPage() { return list; }, [stores, categoryTab, keyword, region, cityCode]); - const regionLabel = formatRegion(region.province, region.city, region.district); + const regionLabel = formatRegionCity(region.province, region.city); const emptyMessage = filterMode === 'manual' && filtered.length === 0 ? '未找到匹配门店' @@ -271,10 +269,11 @@ export default function StoreListPage() { setRegionPickerOpen(false)} onConfirm={(next) => { setFilterMode('manual'); - setRegion(next); + setRegion(toCityLevelRegion(next)); setRegionPickerOpen(false); }} /> From 4b3452eca0302f13e454905144a4b4951f0e253c Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Thu, 9 Jul 2026 21:52:17 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E7=A1=AE=E5=AE=9A=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E9=A1=B5=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/h5-user/src/pages/OrderConfirmPage.tsx | 49 +++++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/apps/h5-user/src/pages/OrderConfirmPage.tsx b/apps/h5-user/src/pages/OrderConfirmPage.tsx index 115b797..aa9a650 100644 --- a/apps/h5-user/src/pages/OrderConfirmPage.tsx +++ b/apps/h5-user/src/pages/OrderConfirmPage.tsx @@ -70,6 +70,7 @@ export default function OrderConfirmPage() { const [addresses, setAddresses] = useState([]); const [addressId, setAddressId] = useState(params.get('addressId') || ''); const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); const [loading, setLoading] = useState(false); const [msg, setMsg] = useState(''); @@ -104,13 +105,35 @@ export default function OrderConfirmPage() { }, [refreshProfile]); useEffect(() => { - if (!productId || !addressId) return; + if (!productId) return; + let cancelled = false; + setPreviewLoading(true); + const body: { productId: string; quantity: number; addressId?: string } = { + productId, + quantity, + }; + if (addressId) body.addressId = addressId; + request('USER_H5', '/trade/orders/preview', { method: 'POST', - body: JSON.stringify({ productId, quantity, addressId }), + body: JSON.stringify(body), }) - .then(setPreview) - .catch((e) => setMsg(e instanceof Error ? e.message : String(e))); + .then((data) => { + if (!cancelled) setPreview(data); + }) + .catch((e) => { + if (!cancelled) { + setPreview(null); + setMsg(e instanceof Error ? e.message : String(e)); + } + }) + .finally(() => { + if (!cancelled) setPreviewLoading(false); + }); + + return () => { + cancelled = true; + }; }, [productId, quantity, addressId]); function updateQuantity(next: number) { @@ -331,9 +354,13 @@ export default function OrderConfirmPage() { 配送方式

- {isCross ? '物流配送' : '小飞侠配送'} + {!addressId + ? '选择地址后确认' + : isCross + ? '物流配送' + : '小飞侠配送'}

- {!isCross && ( + {addressId && !isCross && (

预计24小时内送达

)}
@@ -358,10 +385,14 @@ export default function OrderConfirmPage() { )} - {!preview && productId && addressId && ( + {previewLoading && !preview && productId && (
加载订单信息...
)} + {!previewLoading && !preview && productId && ( +
无法加载商品信息
+ )} + {msg &&

{msg}

}
@@ -376,10 +407,10 @@ export default function OrderConfirmPage() {
From f76eb81274927380bedc38dead3d38d4fc97b5cc Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Thu, 9 Jul 2026 21:57:40 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E5=8F=B3=E4=B8=8A=E8=A7=92=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E4=BF=A1=E6=81=AF=E4=BB=85=E5=B1=95=E7=A4=BA=E4=B8=8D?= =?UTF-8?q?=E5=81=9A=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/h5-user/src/pages/HomePage.tsx | 37 ++++++++++------------------- apps/h5-user/src/styles.css | 17 +++++-------- 2 files changed, 18 insertions(+), 36 deletions(-) diff --git a/apps/h5-user/src/pages/HomePage.tsx b/apps/h5-user/src/pages/HomePage.tsx index 0905d06..7c49df4 100644 --- a/apps/h5-user/src/pages/HomePage.tsx +++ b/apps/h5-user/src/pages/HomePage.tsx @@ -11,6 +11,7 @@ import { FALLBACK_CITY_CODE, resolveUserCity, } from '../lib/wechat-location'; +import { formatRegionCity } from '../lib/region-data'; import CouponBadge from '@dukang/shared-ui/CouponBadge'; type Product = { @@ -44,8 +45,7 @@ export default function HomePage() { const [products, setProducts] = useState([]); const [cities, setCities] = useState([]); const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || FALLBACK_CITY_CODE); - const [locatedCityLabel, setLocatedCityLabel] = useState(''); - const [citySource, setCitySource] = useState<'auto' | 'manual'>('auto'); + const [locationLabel, setLocationLabel] = useState('定位中...'); const [toast, setToast] = useState(''); useEffect(() => { @@ -62,10 +62,13 @@ export default function HomePage() { }, []); useEffect(() => { - if (citySource !== 'auto') return; resolveUserCity().then((resolved) => { - if (!resolved) return; - setLocatedCityLabel(resolved.displayCity); + if (!resolved) { + setLocationLabel('郑州市'); + setCityCode(FALLBACK_CITY_CODE); + return; + } + setLocationLabel(formatRegionCity(resolved.province, resolved.city)); if (resolved.openCity && resolved.cityCode) { setCityCode(resolved.cityCode); } else { @@ -74,7 +77,7 @@ export default function HomePage() { window.setTimeout(() => setToast(''), 2200); } }); - }, [citySource]); + }, []); useEffect(() => { if (!cityCode) return; @@ -95,8 +98,6 @@ export default function HomePage() { setTab(key); } - const selectedCity = cities.find((c) => c.code === cityCode); - const headerCityLabel = locatedCityLabel || selectedCity?.name || '郑州市'; const filtered = products.filter((p) => p.aromaType === tab); const onSale = tab === 'QINGXIANG'; @@ -105,23 +106,9 @@ export default function HomePage() { - location_on - {headerCityLabel} - +
+ location_on + {locationLabel}
)} /> diff --git a/apps/h5-user/src/styles.css b/apps/h5-user/src/styles.css index 05eec1c..7c68771 100644 --- a/apps/h5-user/src/styles.css +++ b/apps/h5-user/src/styles.css @@ -606,21 +606,16 @@ max-width: 46vw; } +.tab-main-city--readonly { + pointer-events: none; + user-select: none; +} + .tab-main-city-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - max-width: 72px; -} - -.tab-main-city-select { - border: none; - background: transparent; - font: inherit; - color: inherit; - max-width: 72px; - opacity: 0.85; - font-size: 11px; + max-width: 120px; } .tab-main-city .material-symbols-outlined { From e20ee1ec7fb60d679d6ca791d4796813a2964ff1 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Thu, 9 Jul 2026 22:03:43 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E4=B8=B4=E6=97=B6=E9=97=AD=E5=BA=97UI?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/h5-shop/src/pages/StatusPage.tsx | 6 +++- apps/h5-shop/src/styles.css | 42 +++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/h5-shop/src/pages/StatusPage.tsx b/apps/h5-shop/src/pages/StatusPage.tsx index 0f5bba9..fad6757 100644 --- a/apps/h5-shop/src/pages/StatusPage.tsx +++ b/apps/h5-shop/src/pages/StatusPage.tsx @@ -86,13 +86,17 @@ export default function StatusPage() { {open ? '营业中' : '临时闭店'} -