From 283e6651aa2cc7df1bad110a0a98a215f28a05e4 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Tue, 1 Sep 2026 15:23:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(mini):=20v4.0.8=20=E5=AE=A2=E6=9C=8D?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E4=B8=BB=E5=8C=85=E8=90=BD=E5=9C=B0=E3=80=81?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E8=AF=AF=E6=8A=A5=E4=BF=AE=E5=A4=8D=EF=BC=8C?= =?UTF-8?q?=E5=90=88=E4=BC=99=E4=BA=BA=E5=BE=AE=E4=BF=A1=E5=86=85=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E4=BF=9D=E5=AD=98=E6=B5=B7=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/components/ActivityPosterPicker.tsx | 18 ++++---- apps/h5-partner/src/lib/wechat-save-image.ts | 31 ++++++++++++++ apps/h5-partner/src/pages/AssocQrcodePage.tsx | 30 ++++++------- apps/h5-partner/src/pages/UsersManagePage.tsx | 28 +++++++++---- apps/h5-partner/src/styles.css | 8 ++++ apps/mini-user/config/index.ts | 4 ++ apps/mini-user/package.json | 2 +- apps/mini-user/src/app.config.ts | 1 + .../src/components/ContactCsButton.tsx | 5 ++- apps/mini-user/src/lib/api.ts | 6 ++- apps/mini-user/src/lib/client-error.ts | 23 +++++++--- apps/mini-user/src/lib/client-location.ts | 8 +--- .../src/lib/client-location.weapp.ts | 8 +--- apps/mini-user/src/lib/client-version.ts | 15 ++++--- apps/mini-user/src/lib/user-location.ts | 34 +++++++++++---- apps/mini-user/src/lib/user-location.weapp.ts | 34 +++++++++++---- apps/mini-user/src/lib/wecom-cs.ts | 23 ++++++---- apps/mini-user/src/pages/open/index.config.ts | 3 ++ apps/mini-user/src/pages/open/index.tsx | 42 +++++++++++++++++++ .../src/pages/order-detail/index.tsx | 13 ++++-- apps/mini-user/types/global.d.ts | 1 + packages/weixin-sdk/src/index.ts | 3 ++ packages/weixin-sdk/src/previewImage.ts | 33 +++++++++++++++ packages/weixin-sdk/src/types.ts | 7 ++++ .../system-config/system-config.registry.ts | 4 +- 25 files changed, 291 insertions(+), 93 deletions(-) create mode 100644 apps/h5-partner/src/lib/wechat-save-image.ts create mode 100644 apps/mini-user/src/pages/open/index.config.ts create mode 100644 apps/mini-user/src/pages/open/index.tsx create mode 100644 packages/weixin-sdk/src/previewImage.ts diff --git a/apps/h5-partner/src/components/ActivityPosterPicker.tsx b/apps/h5-partner/src/components/ActivityPosterPicker.tsx index 92f1124..01afa10 100644 --- a/apps/h5-partner/src/components/ActivityPosterPicker.tsx +++ b/apps/h5-partner/src/components/ActivityPosterPicker.tsx @@ -11,6 +11,7 @@ import { saveActivityPosterSelection, } from '../lib/activity-posters'; import { toastError, toastSuccess } from '../lib/toast'; +import { blobToDataUrl, isHttpImageUrl, previewSaveableImage } from '../lib/wechat-save-image'; import { isWechatEnv } from '../lib/weixin'; const NONE_ID = 'none'; @@ -98,11 +99,13 @@ export default function ActivityPosterPicker({ initialId }: Props) { setDownloading(true); try { if (selectedId === NONE_ID) { + if (isWechatEnv() && isHttpImageUrl(qrcodeUrl)) { + await previewSaveableImage(qrcodeUrl); + return; + } const blob = await fetchAssocQrcodeImage(); if (isWechatEnv()) { - const url = URL.createObjectURL(blob); - urlsRef.current.push(url); - setQrcodeUrl(url); + setQrcodeUrl(await blobToDataUrl(blob)); toastSuccess('请长按上方图片保存到相册'); return; } @@ -118,9 +121,8 @@ export default function ActivityPosterPicker({ initialId }: Props) { } if (isWechatEnv()) { let url = previewUrls[selected.id]; - if (!url) { - url = URL.createObjectURL(blob); - urlsRef.current.push(url); + if (!url || url.startsWith('blob:')) { + url = await blobToDataUrl(blob); setPreviewUrls((prev) => ({ ...prev, [selected.id]: url })); } toastSuccess('请长按上方图片保存到相册'); @@ -172,7 +174,7 @@ export default function ActivityPosterPicker({ initialId }: Props) {
{qrcodeUrl ? (
- 关联码 + 关联码
) : (

关联码尚未生成

@@ -200,7 +202,7 @@ export default function ActivityPosterPicker({ initialId }: Props) { {selectedId === item.id && (
- {item.title} + {item.title} {previewLoading && !previewUrl && (
正在贴入二维码…
)} diff --git a/apps/h5-partner/src/lib/wechat-save-image.ts b/apps/h5-partner/src/lib/wechat-save-image.ts new file mode 100644 index 0000000..6fed121 --- /dev/null +++ b/apps/h5-partner/src/lib/wechat-save-image.ts @@ -0,0 +1,31 @@ +import { isHttpImageUrl } from '@dukang/weixin-sdk'; +import { toastSuccess } from './toast'; +import { isWechatEnv, weixinSdk } from './weixin'; + +export { isHttpImageUrl }; + +export function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(new Error('读取图片失败')); + reader.readAsDataURL(blob); + }); +} + +/** + * 微信内用 previewImage 打开可公网访问的图片,用户长按即可保存。 + * blob / data URL 不能走该接口。 + */ +export async function previewSaveableImage(url: string): Promise { + const href = url.trim(); + if (!isWechatEnv() || !isHttpImageUrl(href)) return false; + try { + await weixinSdk.previewImage(href); + toastSuccess('请长按图片保存到相册'); + return true; + } catch { + toastSuccess('请长按上方图片保存到相册'); + return false; + } +} diff --git a/apps/h5-partner/src/pages/AssocQrcodePage.tsx b/apps/h5-partner/src/pages/AssocQrcodePage.tsx index 78f24fb..9983b99 100644 --- a/apps/h5-partner/src/pages/AssocQrcodePage.tsx +++ b/apps/h5-partner/src/pages/AssocQrcodePage.tsx @@ -4,6 +4,7 @@ import PageHeader from '@dukang/shared-ui/PageHeader'; import type { PartnerAssocSummary } from '@dukang/shared-types'; import { request } from '../lib/api'; import { toastError, toastSuccess } from '../lib/toast'; +import { isHttpImageUrl, previewSaveableImage } from '../lib/wechat-save-image'; import { isWechatEnv } from '../lib/weixin'; import { usePartnerPageView } from '../lib/usePageView'; @@ -11,7 +12,6 @@ export default function AssocQrcodePage() { usePartnerPageView('partner_assoc_qrcode_view'); const navigate = useNavigate(); const [summary, setSummary] = useState(null); - const [previewUrl, setPreviewUrl] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { @@ -21,11 +21,12 @@ export default function AssocQrcodePage() { .finally(() => setLoading(false)); }, []); - useEffect(() => () => { - if (previewUrl) URL.revokeObjectURL(previewUrl); - }, [previewUrl]); - async function downloadQr() { + const publicUrl = summary?.qrcodeUrl; + if (isWechatEnv() && isHttpImageUrl(publicUrl)) { + await previewSaveableImage(publicUrl); + return; + } const token = localStorage.getItem('accessToken'); try { const res = await fetch('/api/v1/partner/assoc/qrcode', { @@ -37,11 +38,6 @@ export default function AssocQrcodePage() { if (!res.ok) throw new Error('下载失败'); const blob = await res.blob(); const url = URL.createObjectURL(blob); - if (isWechatEnv()) { - setPreviewUrl(url); - toastSuccess('请长按图片保存到相册'); - return; - } const a = document.createElement('a'); a.href = url; a.download = `partner-assoc-${summary?.partnerId || 'qr'}.png`; @@ -49,9 +45,8 @@ export default function AssocQrcodePage() { URL.revokeObjectURL(url); toastSuccess('已开始下载'); } catch (e) { - if (summary?.qrcodeUrl) { - setPreviewUrl(summary.qrcodeUrl); - toastSuccess('请长按图片保存到相册'); + if (isWechatEnv() && isHttpImageUrl(publicUrl)) { + await previewSaveableImage(publicUrl); return; } toastError(e instanceof Error ? e.message : '下载失败'); @@ -70,7 +65,8 @@ export default function AssocQrcodePage() {

{summary?.qrcodeUrl ? ( 关联码 @@ -83,11 +79,11 @@ export default function AssocQrcodePage() { - {previewUrl && isWechatEnv() && ( + {isWechatEnv() && summary?.qrcodeUrl ? (

- 微信内请长按上方图片保存 + 微信内请点「下载二维码」后长按保存

- )} + ) : null}
)} diff --git a/apps/h5-partner/src/pages/UsersManagePage.tsx b/apps/h5-partner/src/pages/UsersManagePage.tsx index f893893..aeedf07 100644 --- a/apps/h5-partner/src/pages/UsersManagePage.tsx +++ b/apps/h5-partner/src/pages/UsersManagePage.tsx @@ -13,6 +13,7 @@ import { fetchAssocQrcodeImage, } from '../lib/activity-posters'; import { toastError, toastSuccess } from '../lib/toast'; +import { blobToDataUrl, isHttpImageUrl, previewSaveableImage } from '../lib/wechat-save-image'; import { isWechatEnv } from '../lib/weixin'; import { usePartnerPageView } from '../lib/usePageView'; @@ -58,7 +59,7 @@ export default function UsersManagePage() { }, []); useEffect(() => () => { - if (previewUrl) URL.revokeObjectURL(previewUrl); + if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl); }, [previewUrl]); useEffect(() => { @@ -131,12 +132,17 @@ export default function UsersManagePage() { async function downloadMainImage() { const posterId = summary?.activityPosterId; try { + if (isWechatEnv() && !posterId && isHttpImageUrl(summary?.qrcodeUrl)) { + await previewSaveableImage(summary.qrcodeUrl); + return; + } const blob = posterId ? await fetchActivityPosterImage(posterId) : await fetchAssocQrcodeImage(); if (isWechatEnv()) { - setPreviewUrl(URL.createObjectURL(blob)); - toastSuccess('请长按图片保存到相册'); + if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl); + setPreviewUrl(await blobToDataUrl(blob)); + toastSuccess('请长按上方图片保存到相册'); return; } downloadBlob(blob, posterId @@ -144,7 +150,11 @@ export default function UsersManagePage() { : `partner-assoc-${summary?.partnerId || 'qr'}.png`); toastSuccess('已开始下载'); } catch (e) { - if (!posterId && summary?.qrcodeUrl) { + if (!posterId && isHttpImageUrl(summary?.qrcodeUrl)) { + if (isWechatEnv()) { + await previewSaveableImage(summary.qrcodeUrl); + return; + } setPreviewUrl(summary.qrcodeUrl); toastSuccess('请长按图片保存到相册'); return; @@ -182,6 +192,7 @@ export default function UsersManagePage() {

{summary?.activityPosterId && (previewUrl || heroUrl) ? ( 活动图正在生成活动图…

) : previewUrl || summary?.qrcodeUrl ? ( 关联码 void downloadMainImage()}> {summary?.activityPosterId ? '下载活动图' : '下载二维码'} - {previewUrl && isWechatEnv() && ( -

微信内请长按上方图片保存

- )} + {isWechatEnv() && (previewUrl || summary?.qrcodeUrl) ? ( +

+ 微信内请点「{summary?.activityPosterId ? '下载活动图' : '下载二维码'}」后长按保存 +

+ ) : null} diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css index 914374a..7cf6371 100644 --- a/apps/h5-partner/src/styles.css +++ b/apps/h5-partner/src/styles.css @@ -4087,3 +4087,11 @@ header:has(> .app-page-title:only-child), width: 200px; height: 200px; } + +/* 微信内长按保存:不要禁用系统菜单 */ +img.partner-longpress-img { + -webkit-touch-callout: default; + -webkit-user-select: none; + user-select: none; + pointer-events: auto; +} diff --git a/apps/mini-user/config/index.ts b/apps/mini-user/config/index.ts index a928fbf..25a38ca 100644 --- a/apps/mini-user/config/index.ts +++ b/apps/mini-user/config/index.ts @@ -14,6 +14,9 @@ const API_ORIGIN = (isDevMode ? 'http://192.168.1.10:3010' : 'https://api.dukanghaoke.com'); const requireFromApp = createRequire(path.resolve(__dirname, '../package.json')); +const { version: APP_PKG_VERSION } = requireFromApp(path.resolve(__dirname, '../package.json')) as { + version: string; +}; /** 解析包目录/文件,避免 pnpm/Vite 把同一 runtime 打成两份 → useDidShow 读到空 reactMeta */ function resolvePkgFile(id: string): string { @@ -54,6 +57,7 @@ export default defineConfig(async () => ({ }, defineConstants: { TARO_APP_API_ORIGIN: JSON.stringify(API_ORIGIN), + TARO_APP_VERSION: JSON.stringify(APP_PKG_VERSION), }, copy: { patterns: [ diff --git a/apps/mini-user/package.json b/apps/mini-user/package.json index 4577f9a..20db33b 100644 --- a/apps/mini-user/package.json +++ b/apps/mini-user/package.json @@ -1,6 +1,6 @@ { "name": "@dukang/mini-user", - "version": "3.5.16", + "version": "4.0.4", "private": true, "description": "杜康好客 · C 端用户微信小程序(Taro)", "scripts": { diff --git a/apps/mini-user/src/app.config.ts b/apps/mini-user/src/app.config.ts index 3184325..62d0c90 100644 --- a/apps/mini-user/src/app.config.ts +++ b/apps/mini-user/src/app.config.ts @@ -4,6 +4,7 @@ export default defineAppConfig({ 'pages/stores/index', 'pages/benefit/index', 'pages/mine/index', + 'pages/open/index', ], subPackages: [ { root: 'pages/product-detail', pages: ['index'] }, diff --git a/apps/mini-user/src/components/ContactCsButton.tsx b/apps/mini-user/src/components/ContactCsButton.tsx index d03df90..11ab976 100644 --- a/apps/mini-user/src/components/ContactCsButton.tsx +++ b/apps/mini-user/src/components/ContactCsButton.tsx @@ -3,7 +3,7 @@ import { Button, Text } from '@tarojs/components'; import type { ReactNode } from 'react'; import { toast } from '../lib/api'; import { getBrandAssetsSync, loadBrandAssets } from '../lib/brand-assets'; -import { formatOpenCsError, openWecomCustomerServiceChat } from '../lib/wecom-cs'; +import { buildCsSharePath, buildCsShareTitle, formatOpenCsError, openWecomCustomerServiceChat } from '../lib/wecom-cs'; export type ContactCsSessionContext = { orderId?: string; @@ -91,6 +91,9 @@ export default function ContactCsButton({ className={className} openType="contact" sessionFrom={buildCsSessionFrom(session)} + showMessageCard + sendMessageTitle={buildCsShareTitle(session?.orderNo)} + sendMessagePath={buildCsSharePath(session?.orderId)} hoverClass="none" > {typeof children === 'string' ? {children} : children} diff --git a/apps/mini-user/src/lib/api.ts b/apps/mini-user/src/lib/api.ts index f7a0821..69b90ea 100644 --- a/apps/mini-user/src/lib/api.ts +++ b/apps/mini-user/src/lib/api.ts @@ -130,10 +130,12 @@ export function toast(title: string, icon: 'success' | 'error' | 'none' = 'none' const text = title.trim(); if (!text) return; if (icon !== 'success' && showFloatingToast(text)) { - void Taro.hideToast(); + void Promise.resolve(Taro.hideToast() as void | Promise).catch(() => {}); return; } - Taro.showToast({ title: text, icon, duration: 1800 }); + void Promise.resolve( + Taro.showToast({ title: text, icon, duration: 1800 }) as void | Promise, + ).catch(() => {}); } export type SessionPayload = { diff --git a/apps/mini-user/src/lib/client-error.ts b/apps/mini-user/src/lib/client-error.ts index e46a1f6..1cb653a 100644 --- a/apps/mini-user/src/lib/client-error.ts +++ b/apps/mini-user/src/lib/client-error.ts @@ -21,6 +21,14 @@ export type ClientErrorPayload = { extra?: Record; }; +/** 微信预期失败,不应按 P1 未捕获上报 */ +const IGNORED_REJECTION = + /getLocation:fail|hideToast:fail:toast can't be found|showToast:fail|authorize:fail auth deny/i; + +function shouldIgnoreRejection(message: string): boolean { + return IGNORED_REJECTION.test(message); +} + function currentPagePath(): string | undefined { try { const pages = Taro.getCurrentPages(); @@ -97,6 +105,7 @@ export function installClientErrorReporting(): void { : typeof reason === 'string' ? reason : JSON.stringify(reason); + if (shouldIgnoreRejection(message || '')) return; const stack = reason instanceof Error ? reason.stack : undefined; reportClientError({ level: 'error', @@ -121,15 +130,17 @@ export function installClientErrorReporting(): void { }); window.addEventListener('unhandledrejection', (ev) => { const reason = ev.reason; + const message = + reason instanceof Error + ? reason.message + : typeof reason === 'string' + ? reason + : 'unhandledrejection'; + if (shouldIgnoreRejection(message)) return; reportClientError({ level: 'error', category: 'unhandled_rejection', - message: - reason instanceof Error - ? reason.message - : typeof reason === 'string' - ? reason - : 'unhandledrejection', + message, stack: reason instanceof Error ? reason.stack : undefined, }); }); diff --git a/apps/mini-user/src/lib/client-location.ts b/apps/mini-user/src/lib/client-location.ts index c9c772e..267103e 100644 --- a/apps/mini-user/src/lib/client-location.ts +++ b/apps/mini-user/src/lib/client-location.ts @@ -14,13 +14,7 @@ export async function tryGetClientGpsLocation(): Promise((resolve, reject) => { - Taro.getLocation({ - type: 'gcj02', - success: resolve, - fail: reject, - }); - }); + const loc = await Taro.getLocation({ type: 'gcj02' }); return { latitude: loc.latitude, longitude: loc.longitude }; } catch { return null; diff --git a/apps/mini-user/src/lib/client-location.weapp.ts b/apps/mini-user/src/lib/client-location.weapp.ts index 3735458..d3fb039 100644 --- a/apps/mini-user/src/lib/client-location.weapp.ts +++ b/apps/mini-user/src/lib/client-location.weapp.ts @@ -11,13 +11,7 @@ export type ClientGpsLocation = { export async function tryGetClientGpsLocation(): Promise { try { - const loc = await new Promise<{ latitude: number; longitude: number }>((resolve, reject) => { - Taro.getLocation({ - type: 'gcj02', - success: resolve, - fail: reject, - }); - }); + const loc = await Taro.getLocation({ type: 'gcj02' }); return { latitude: loc.latitude, longitude: loc.longitude }; } catch { return null; diff --git a/apps/mini-user/src/lib/client-version.ts b/apps/mini-user/src/lib/client-version.ts index 0175aa6..5b441aa 100644 --- a/apps/mini-user/src/lib/client-version.ts +++ b/apps/mini-user/src/lib/client-version.ts @@ -1,13 +1,18 @@ import Taro from '@tarojs/taro'; import { fetchClientConfig } from './pay-wechat'; -/** 与 package.json version 同步,供服务端 minClientVersion 比对 */ -export const APP_VERSION = '3.5.16'; +/** 与 package.json version 同步(Taro defineConstants 注入),供 minClientVersion 比对 */ +export const APP_VERSION = + (typeof TARO_APP_VERSION !== 'undefined' && String(TARO_APP_VERSION).trim()) || '4.0.4'; -export const APP_VERSION_LABEL = `v${APP_VERSION}`; +export const APP_VERSION_LABEL = `v${APP_VERSION.replace(/^v/i, '')}`; + +function normalizeVersion(v: string): string { + return String(v || '').trim().replace(/^v/i, ''); +} function parseSemver(v: string): number[] { - return v.split('.').map((n) => parseInt(n, 10) || 0); + return normalizeVersion(v).split('.').map((n) => parseInt(n, 10) || 0); } export function compareSemver(a: string, b: string): number { @@ -89,7 +94,7 @@ async function enforceMinClientVersion(min: string) { export async function checkClientVersionGate() { try { const config = await fetchClientConfig(); - const min = config.minClientVersion?.trim(); + const min = normalizeVersion(config.minClientVersion ?? ''); if (min && compareSemver(APP_VERSION, min) < 0) { await enforceMinClientVersion(min); } diff --git a/apps/mini-user/src/lib/user-location.ts b/apps/mini-user/src/lib/user-location.ts index 29a0f72..867e933 100644 --- a/apps/mini-user/src/lib/user-location.ts +++ b/apps/mini-user/src/lib/user-location.ts @@ -59,6 +59,23 @@ function clearLocationDenied() { } } +function wxErrorMessage(err: unknown): string { + if (!err) return ''; + if (typeof err === 'string') return err; + if (err instanceof Error) return err.message; + if (typeof err === 'object') { + const o = err as { errMsg?: unknown; message?: unknown }; + if (typeof o.errMsg === 'string' && o.errMsg) return o.errMsg; + if (typeof o.message === 'string' && o.message) return o.message; + try { + return JSON.stringify(err); + } catch { + return ''; + } + } + return String(err); +} + function isDenyMessage(errMsg?: string): boolean { return /auth deny|authorize|permission|denied|拒绝|用户拒绝|getLocation:fail/i.test( errMsg || '', @@ -179,14 +196,13 @@ async function promptLocationAuthOnce() { }).catch(() => {}); } -function getMiniLocation(): Promise { - return new Promise((resolve, reject) => { - Taro.getLocation({ - type: 'gcj02', - success: resolve, - fail: reject, - }); - }); +async function getMiniLocation(): Promise { + const setting = await Taro.getSetting().catch(() => null); + if (setting?.authSetting?.['scope.userLocation'] === false) { + throw new Error('getLocation:fail auth deny'); + } + // 只用返回的 Promise,避免 callback + Promise 双重 reject 变成未捕获 + return Taro.getLocation({ type: 'gcj02' }); } async function resolveViaH5Jssdk(): Promise { @@ -263,7 +279,7 @@ export async function resolveUserCity(force = false): Promise return resolved; } } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); + const errMsg = wxErrorMessage(err); const denied = isDenyMessage(errMsg); if (denied && !isLocationDenied()) { await promptLocationAuthOnce(); diff --git a/apps/mini-user/src/lib/user-location.weapp.ts b/apps/mini-user/src/lib/user-location.weapp.ts index a3ec5c7..ddb94c6 100644 --- a/apps/mini-user/src/lib/user-location.weapp.ts +++ b/apps/mini-user/src/lib/user-location.weapp.ts @@ -57,6 +57,23 @@ function clearLocationDenied() { } } +function wxErrorMessage(err: unknown): string { + if (!err) return ''; + if (typeof err === 'string') return err; + if (err instanceof Error) return err.message; + if (typeof err === 'object') { + const o = err as { errMsg?: unknown; message?: unknown }; + if (typeof o.errMsg === 'string' && o.errMsg) return o.errMsg; + if (typeof o.message === 'string' && o.message) return o.message; + try { + return JSON.stringify(err); + } catch { + return ''; + } + } + return String(err); +} + function isDenyMessage(errMsg?: string): boolean { return /auth deny|authorize|permission|denied|拒绝|用户拒绝|getLocation:fail/i.test( errMsg || '', @@ -175,14 +192,13 @@ async function promptLocationAuthOnce() { }).catch(() => {}); } -function getMiniLocation(): Promise { - return new Promise((resolve, reject) => { - Taro.getLocation({ - type: 'gcj02', - success: resolve, - fail: reject, - }); - }); +async function getMiniLocation(): Promise { + const setting = await Taro.getSetting().catch(() => null); + if (setting?.authSetting?.['scope.userLocation'] === false) { + throw new Error('getLocation:fail auth deny'); + } + // 只用返回的 Promise,避免 callback + Promise 双重 reject 变成未捕获 + return Taro.getLocation({ type: 'gcj02' }); } export async function resolveUserCity(force = false): Promise { @@ -211,7 +227,7 @@ export async function resolveUserCity(force = false): Promise return resolved; } } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); + const errMsg = wxErrorMessage(err); const denied = isDenyMessage(errMsg); if (denied && !isLocationDenied()) { await promptLocationAuthOnce(); diff --git a/apps/mini-user/src/lib/wecom-cs.ts b/apps/mini-user/src/lib/wecom-cs.ts index 74f3e6f..dfdbdf3 100644 --- a/apps/mini-user/src/lib/wecom-cs.ts +++ b/apps/mini-user/src/lib/wecom-cs.ts @@ -4,6 +4,17 @@ export type WecomCsSessionContext = { from?: string; }; +/** 客服消息卡片必须落主包页;分包(订单详情)作启动页会白屏 */ +export function buildCsSharePath(orderId?: string): string { + const id = (orderId || '').trim(); + return id ? `pages/open/index?id=${encodeURIComponent(id)}` : 'pages/home/index'; +} + +export function buildCsShareTitle(orderNo?: string): string { + const no = (orderNo || '').trim(); + return no ? `订单 ${no}` : '杜康好客'; +} + type OpenCsChatOption = { extInfo: { url: string }; corpId: string; @@ -97,16 +108,10 @@ export async function openWecomCustomerServiceChat(params: { const option: OpenCsChatOption = { extInfo: { url }, corpId, + showMessageCard: true, + sendMessageTitle: buildCsShareTitle(params.session?.orderNo), + sendMessagePath: buildCsSharePath(params.session?.orderId), }; - if (params.session?.orderNo || params.session?.orderId) { - option.showMessageCard = true; - option.sendMessageTitle = params.session.orderNo - ? `订单 ${params.session.orderNo}` - : '订单咨询'; - if (params.session.orderId) { - option.sendMessagePath = `pages/order-detail/index?id=${params.session.orderId}`; - } - } await new Promise((resolve, reject) => { wxApi({ diff --git a/apps/mini-user/src/pages/open/index.config.ts b/apps/mini-user/src/pages/open/index.config.ts new file mode 100644 index 0000000..0d044a3 --- /dev/null +++ b/apps/mini-user/src/pages/open/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '杜康好客', +}); diff --git a/apps/mini-user/src/pages/open/index.tsx b/apps/mini-user/src/pages/open/index.tsx new file mode 100644 index 0000000..0a41e28 --- /dev/null +++ b/apps/mini-user/src/pages/open/index.tsx @@ -0,0 +1,42 @@ +import { View, Text } from '@tarojs/components'; +import Taro, { useLoad } from '@tarojs/taro'; +import PageShell from '../../components/PageShell'; +import { goLogin } from '../../lib/auth-nav'; +import { isLoggedIn } from '../../lib/api'; + +function goHome() { + Taro.switchTab({ url: '/pages/home/index' }).catch(() => { + Taro.reLaunch({ url: '/pages/home/index' }); + }); +} + +function openOrder(orderId: string) { + const detail = `/pages/order-detail/index?id=${encodeURIComponent(orderId)}`; + if (!isLoggedIn()) { + goLogin(detail); + return; + } + Taro.redirectTo({ url: detail }).catch(() => { + Taro.reLaunch({ url: detail }); + }); +} + +/** 主包落地:客服卡片 / 外链打开分包页(订单详情)前先落到这里,避免白屏 */ +export default function OpenPage() { + useLoad((query?: Record) => { + const orderId = String(query?.id || query?.orderId || '').trim(); + if (!orderId) { + goHome(); + return; + } + openOrder(orderId); + }); + + return ( + + + 正在打开… + + + ); +} diff --git a/apps/mini-user/src/pages/order-detail/index.tsx b/apps/mini-user/src/pages/order-detail/index.tsx index 67991ce..c80fcbd 100644 --- a/apps/mini-user/src/pages/order-detail/index.tsx +++ b/apps/mini-user/src/pages/order-detail/index.tsx @@ -116,6 +116,7 @@ export default function OrderDetailPage() { const orderId = router.params.id ?? ''; usePageView('order_detail_view', orderId ? { orderId } : undefined); const [order, setOrder] = useState(null); + const [loadError, setLoadError] = useState(''); const [confirming, setConfirming] = useState(false); const [latestTrack, setLatestTrack] = useState(null); const [estimatedArrival, setEstimatedArrival] = useState(null); @@ -147,14 +148,18 @@ export default function OrderDetailPage() { } useEffect(() => { - if (!orderId) return; + if (!orderId) { + setLoadError('订单信息缺失'); + return; + } request(`/trade/orders/${orderId}`) .then((data) => { setOrder(data); + setLoadError(''); void loadOrderTrack(data.delivery, data.status); applyLocalDeliveryHint(data, setLocalHintHtml); }) - .catch((e) => toast(e instanceof Error ? e.message : '加载失败')); + .catch((e) => setLoadError(e instanceof Error ? e.message : '加载失败')); }, [orderId]); useDidShow(() => { @@ -293,7 +298,9 @@ export default function OrderDetailPage() { /> {!order ? ( - 加载中… + Taro.switchTab({ url: '/pages/home/index' }) : undefined}> + {loadError || '加载中…'} + ) : ( <> diff --git a/apps/mini-user/types/global.d.ts b/apps/mini-user/types/global.d.ts index 49d6fdd..1a4eb4a 100644 --- a/apps/mini-user/types/global.d.ts +++ b/apps/mini-user/types/global.d.ts @@ -13,6 +13,7 @@ declare const defineAppConfig: (config: Record) => Record) => Record; declare const TARO_APP_API_ORIGIN: string; +declare const TARO_APP_VERSION: string; /** 微信小程序全局(Taro 未封装的 API 如 openBusinessView 需直接调用) */ declare const wx: { diff --git a/packages/weixin-sdk/src/index.ts b/packages/weixin-sdk/src/index.ts index 91b6814..2c39aca 100644 --- a/packages/weixin-sdk/src/index.ts +++ b/packages/weixin-sdk/src/index.ts @@ -25,6 +25,7 @@ export { scanQrCode } from './scan'; export { invokeWechatPay } from './pay'; export { chooseWechatImages, canUseWechatChooseImage, formatChooseImageFailMessage } from './chooseImage'; export type { ChooseWechatImageOptions } from './chooseImage'; +export { previewWechatImage, isHttpImageUrl } from './previewImage'; export { setWechatShareData, canUseWechatShare, @@ -53,6 +54,7 @@ import { getWechatLocation, getWechatLocationDetailed } from './location'; import { scanQrCode } from './scan'; import { invokeWechatPay } from './pay'; import { chooseWechatImages } from './chooseImage'; +import { previewWechatImage } from './previewImage'; import { setWechatShareData, shareViaWechatSdk } from './share'; import { wechatLogin, @@ -76,6 +78,7 @@ export function createWeixinSdk(config: WeixinSdkConfig) { scanQrCode: (options?: Parameters[1]) => scanQrCode(config, options), chooseImages: (options?: Parameters[1]) => chooseWechatImages(config, options), + previewImage: (url: string) => previewWechatImage(config, url), pay: (prepay: Parameters[0]) => invokeWechatPay(prepay, { apiBase: config.apiBase ?? '/api/v1', diff --git a/packages/weixin-sdk/src/previewImage.ts b/packages/weixin-sdk/src/previewImage.ts new file mode 100644 index 0000000..08544f2 --- /dev/null +++ b/packages/weixin-sdk/src/previewImage.ts @@ -0,0 +1,33 @@ +import { ensureJssdkReady } from './jssdk'; +import type { WeixinSdkConfig } from './types'; + +export function isHttpImageUrl(url?: string | null): url is string { + return !!url && /^https?:\/\//i.test(url.trim()); +} + +/** 微信内预览图片,便于长按保存到相册(须是可公网访问的 http(s) 地址) */ +export async function previewWechatImage(config: WeixinSdkConfig, url: string): Promise { + const href = url.trim(); + if (!isHttpImageUrl(href)) { + throw new Error('图片地址无效'); + } + + await ensureJssdkReady({ + apiBase: config.apiBase ?? '/api/v1', + clientApp: config.clientApp, + getAccessToken: config.getAccessToken, + }); + + if (!window.wx?.previewImage) { + throw new Error('微信预览图片不可用'); + } + + await new Promise((resolve, reject) => { + window.wx!.previewImage!({ + current: href, + urls: [href], + success: () => resolve(), + fail: (res) => reject(new Error(res?.errMsg || '预览失败')), + }); + }); +} diff --git a/packages/weixin-sdk/src/types.ts b/packages/weixin-sdk/src/types.ts index 28647f6..2e0eae6 100644 --- a/packages/weixin-sdk/src/types.ts +++ b/packages/weixin-sdk/src/types.ts @@ -58,6 +58,12 @@ export type WxApi = { success?: (res: { localData: string }) => void; fail?: (res: { errMsg: string }) => void; }) => void; + previewImage: (options: { + current: string; + urls: string[]; + success?: () => void; + fail?: (res: { errMsg: string }) => void; + }) => void; updateAppMessageShareData: (options: { title: string; desc: string; @@ -121,6 +127,7 @@ export const DEFAULT_JS_API_LIST = [ 'chooseWXPay', 'chooseImage', 'getLocalImgData', + 'previewImage', 'updateAppMessageShareData', 'updateTimelineShareData', 'onMenuShareAppMessage', diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts index 58ae029..900b9fb 100644 --- a/server/dukang-api/src/common/system-config/system-config.registry.ts +++ b/server/dukang-api/src/common/system-config/system-config.registry.ts @@ -144,8 +144,8 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [ group: G.wechat_mini, type: 'string', requiresRestart: false, - placeholder: '3.4.15', - description: 'semver 格式;客户端低于此版本时提示更新', + placeholder: '4.0.4', + description: 'semver 格式(可带或不带 v);客户端低于此版本时提示更新', }, { key: 'USER_H5_URL',