feat: 添加 H5 跳转小程序功能

- 后端: 新增 JS-SDK 签名接口和 URL Scheme 生成接口
- 前端: 创建 MiniProgramLauncher 组件,支持环境自适应
  - 微信内 H5: 使用 wx-open-launch-weapp 开放标签
  - 外部浏览器: 使用 URL Scheme 拉起微信
  - PC 端: 显示小程序码引导扫码
- 引入微信 JS-SDK (jweixin-1.6.0.js)
- 新增 miniprogramService 服务层封装 API 调用

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-12 16:56:04 +08:00
parent bbe4cca2d9
commit 9f99ea7aee
8 changed files with 1200 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
/**
* 小程序码显示组件
* 用于 PC 端显示小程序码供用户扫描
*/
import React from 'react';
import {
Box,
VStack,
Text,
Image,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure,
Icon,
} from '@chakra-ui/react';
import { FiSmartphone } from 'react-icons/fi';
// 默认小程序码图片(可替换为实际的小程序码)
// 注意:需要在微信公众平台生成小程序码图片
const DEFAULT_QR_CODE = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=weixin://dl/business/?t=placeholder';
/**
* PC 端小程序码显示组件
* @param {Object} props
* @param {string} [props.path] - 小程序页面路径(用于显示提示)
* @param {string} [props.qrCodeUrl] - 小程序码图片 URL
* @param {React.ReactNode} props.children - 按钮内容
* @param {string} [props.title] - 弹窗标题
* @param {string} [props.description] - 描述文案
* @param {Object} [props.buttonProps] - 按钮属性
*/
const QRCodeDisplay = ({
path = '',
qrCodeUrl,
children,
title = '扫码打开小程序',
description = '请使用微信扫描下方二维码',
buttonProps = {},
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
// 使用传入的二维码或默认二维码
const qrCode = qrCodeUrl || DEFAULT_QR_CODE;
return (
<>
<Button
onClick={onOpen}
colorScheme="green"
leftIcon={<Icon as={FiSmartphone} />}
{...buttonProps}
>
{children || '打开小程序'}
</Button>
<Modal isOpen={isOpen} onClose={onClose} isCentered size="sm">
<ModalOverlay />
<ModalContent>
<ModalHeader textAlign="center">{title}</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<VStack spacing={4}>
{/* 小程序码图片 */}
<Box
p={4}
bg="white"
borderRadius="xl"
boxShadow="lg"
border="1px solid"
borderColor="gray.100"
>
<Image
src={qrCode}
alt="小程序码"
boxSize="200px"
objectFit="contain"
fallback={
<Box
boxSize="200px"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.100"
borderRadius="md"
>
<Text color="gray.500" fontSize="sm">
加载中...
</Text>
</Box>
}
/>
</Box>
{/* 说明文案 */}
<VStack spacing={1}>
<Text color="gray.700" fontWeight="medium">
{description}
</Text>
<Text color="gray.500" fontSize="sm">
打开微信扫一扫即可访问
</Text>
</VStack>
{/* 提示信息 */}
<Box
w="100%"
p={3}
bg="green.50"
borderRadius="md"
border="1px solid"
borderColor="green.100"
>
<VStack spacing={1} align="start">
<Text fontSize="xs" color="green.700" fontWeight="medium">
温馨提示
</Text>
<Text fontSize="xs" color="green.600">
请确保手机已安装微信 App
</Text>
<Text fontSize="xs" color="green.600">
扫码后点击"打开小程序"按钮
</Text>
{path && (
<Text fontSize="xs" color="green.600">
将跳转到: {path}
</Text>
)}
</VStack>
</Box>
</VStack>
</ModalBody>
</ModalContent>
</Modal>
</>
);
};
export default QRCodeDisplay;

View File

@@ -0,0 +1,193 @@
/**
* URL Scheme 跳转组件
* 用于外部浏览器跳转小程序
*/
import React, { useState, useCallback } from 'react';
import {
Box,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
Text,
VStack,
useDisclosure,
useToast,
Icon,
} from '@chakra-ui/react';
import { FiExternalLink, FiCopy, FiCheck } from 'react-icons/fi';
import { generateUrlScheme, openUrlScheme } from '@services/miniprogramService';
import { isIOSDevice } from './hooks/useWechatEnvironment';
/**
* URL Scheme 跳转组件
* @param {Object} props
* @param {string} [props.path] - 小程序页面路径
* @param {string} [props.query] - 页面参数
* @param {React.ReactNode} props.children - 按钮内容
* @param {Function} [props.onSuccess] - 跳转成功回调
* @param {Function} [props.onError] - 跳转失败回调
* @param {Object} [props.buttonProps] - 按钮属性
*/
const UrlSchemeLauncher = ({
path = '',
query = '',
children,
onSuccess,
onError,
buttonProps = {},
}) => {
const [loading, setLoading] = useState(false);
const [openlink, setOpenlink] = useState(null);
const [copied, setCopied] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
const isIOS = isIOSDevice();
// 处理点击跳转
const handleLaunch = useCallback(async () => {
try {
setLoading(true);
// 生成 URL Scheme
const result = await generateUrlScheme({ path, query });
if (!result?.openlink) {
throw new Error('生成跳转链接失败');
}
setOpenlink(result.openlink);
// 尝试直接跳转
const success = openUrlScheme(result.openlink);
if (success) {
onSuccess?.();
// iOS 上可能会弹出确认框,显示引导弹窗
if (isIOS) {
setTimeout(() => {
onOpen();
}, 500);
}
} else {
// 跳转失败,显示引导弹窗
onOpen();
}
} catch (error) {
console.error('[UrlSchemeLauncher] error:', error);
toast({
title: '跳转失败',
description: error.message || '请稍后重试',
status: 'error',
duration: 3000,
});
onError?.(error);
} finally {
setLoading(false);
}
}, [path, query, onSuccess, onError, onOpen, toast, isIOS]);
// 复制链接
const handleCopy = useCallback(async () => {
if (!openlink) return;
try {
await navigator.clipboard.writeText(openlink);
setCopied(true);
toast({
title: '已复制',
description: '请在微信中打开',
status: 'success',
duration: 2000,
});
setTimeout(() => setCopied(false), 2000);
} catch (error) {
toast({
title: '复制失败',
description: '请手动复制',
status: 'error',
duration: 2000,
});
}
}, [openlink, toast]);
// 再次尝试跳转
const handleRetry = useCallback(() => {
if (openlink) {
openUrlScheme(openlink);
}
}, [openlink]);
return (
<>
<Button
onClick={handleLaunch}
isLoading={loading}
loadingText="正在跳转..."
colorScheme="green"
leftIcon={<Icon as={FiExternalLink} />}
{...buttonProps}
>
{children || '打开小程序'}
</Button>
{/* 引导弹窗 */}
<Modal isOpen={isOpen} onClose={onClose} isCentered size="sm">
<ModalOverlay />
<ModalContent mx={4}>
<ModalHeader>打开小程序</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<Text color="gray.600" fontSize="sm">
{isIOS
? '如果没有自动跳转,请点击下方按钮重试'
: '请在弹出的对话框中选择"打开微信"'}
</Text>
<Box
p={3}
bg="gray.50"
borderRadius="md"
fontSize="xs"
color="gray.500"
>
<Text fontWeight="medium" mb={1}>提示</Text>
<Text>1. 确保已安装微信</Text>
<Text>2. 点击"打开微信"按钮</Text>
<Text>3. 如果没有反应请复制链接到微信打开</Text>
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<VStack spacing={2} width="100%">
<Button
colorScheme="green"
width="100%"
onClick={handleRetry}
leftIcon={<Icon as={FiExternalLink} />}
>
打开微信
</Button>
<Button
variant="outline"
width="100%"
onClick={handleCopy}
leftIcon={<Icon as={copied ? FiCheck : FiCopy} />}
>
{copied ? '已复制' : '复制链接'}
</Button>
</VStack>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
export default UrlSchemeLauncher;

View File

@@ -0,0 +1,203 @@
/**
* 微信开放标签封装组件
* 用于在微信内 H5 跳转小程序
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Spinner, Text, Button as ChakraButton } from '@chakra-ui/react';
import { getJsSdkConfig } from '@services/miniprogramService';
// 小程序原始 ID
const MINIPROGRAM_ORIGINAL_ID = 'gh_fd2fd8dd2fb5';
/**
* 微信开放标签组件
* @param {Object} props
* @param {string} [props.path] - 小程序页面路径
* @param {string} [props.query] - 页面参数
* @param {React.ReactNode} props.children - 按钮内容
* @param {Function} [props.onLaunch] - 跳转成功回调
* @param {Function} [props.onError] - 跳转失败回调
* @param {Object} [props.buttonStyle] - 按钮样式
*/
const WxOpenLaunchWeapp = ({
path = '',
query = '',
children,
onLaunch,
onError,
buttonStyle = {},
}) => {
const [ready, setReady] = useState(false);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const launchBtnRef = useRef(null);
// 初始化微信 JS-SDK
const initWxSdk = useCallback(async () => {
try {
setLoading(true);
setError(null);
// 获取当前页面 URL不含 hash
const currentUrl = window.location.href.split('#')[0];
// 获取签名配置
const config = await getJsSdkConfig(currentUrl);
if (!config) {
throw new Error('获取签名配置失败');
}
// 检查 wx 对象是否存在
if (typeof wx === 'undefined') {
throw new Error('微信 JS-SDK 未加载');
}
// 配置 wx
wx.config({
debug: false,
appId: config.appId,
timestamp: config.timestamp,
nonceStr: config.nonceStr,
signature: config.signature,
jsApiList: config.jsApiList || [],
openTagList: config.openTagList || ['wx-open-launch-weapp'],
});
// 监听 ready 事件
wx.ready(() => {
console.log('[WxOpenLaunchWeapp] wx.ready');
setReady(true);
setLoading(false);
});
// 监听 error 事件
wx.error((err) => {
console.error('[WxOpenLaunchWeapp] wx.error:', err);
setError(err.errMsg || '初始化失败');
setLoading(false);
});
} catch (err) {
console.error('[WxOpenLaunchWeapp] initWxSdk error:', err);
setError(err.message || '初始化失败');
setLoading(false);
}
}, []);
useEffect(() => {
initWxSdk();
}, [initWxSdk]);
// 监听开放标签事件
useEffect(() => {
const btn = launchBtnRef.current;
if (!btn) return;
const handleLaunch = (e) => {
console.log('[WxOpenLaunchWeapp] launch success:', e.detail);
onLaunch?.(e.detail);
};
const handleError = (e) => {
console.error('[WxOpenLaunchWeapp] launch error:', e.detail);
onError?.(e.detail);
};
btn.addEventListener('launch', handleLaunch);
btn.addEventListener('error', handleError);
return () => {
btn.removeEventListener('launch', handleLaunch);
btn.removeEventListener('error', handleError);
};
}, [ready, onLaunch, onError]);
// 构建小程序路径
const mpPath = query ? `${path}?${query}` : path;
// 默认按钮样式
const defaultButtonStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px 24px',
backgroundColor: '#07c160',
color: '#ffffff',
fontSize: '16px',
fontWeight: '500',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
width: '100%',
...buttonStyle,
};
// 加载中状态
if (loading) {
return (
<Box display="flex" alignItems="center" justifyContent="center" py={3}>
<Spinner size="sm" mr={2} />
<Text fontSize="sm" color="gray.500">正在初始化...</Text>
</Box>
);
}
// 错误状态
if (error) {
return (
<ChakraButton
colorScheme="gray"
isDisabled
width="100%"
py={3}
>
{error}
</ChakraButton>
);
}
// 渲染开放标签
if (ready) {
// 微信开放标签需要使用纯 HTML 字符串,不支持 JSX
const buttonText = typeof children === 'string' ? children : '打开小程序';
const htmlContent = `
<style>
.wx-launch-btn {
display: flex;
align-items: center;
justify-content: center;
padding: ${defaultButtonStyle.padding};
background-color: ${defaultButtonStyle.backgroundColor};
color: ${defaultButtonStyle.color};
font-size: ${defaultButtonStyle.fontSize};
font-weight: ${defaultButtonStyle.fontWeight};
border-radius: ${defaultButtonStyle.borderRadius};
border: none;
cursor: pointer;
width: 100%;
box-sizing: border-box;
}
</style>
<button class="wx-launch-btn">${buttonText}</button>
`;
return (
<Box position="relative">
<wx-open-launch-weapp
ref={launchBtnRef}
id="launch-btn"
username={MINIPROGRAM_ORIGINAL_ID}
path={mpPath}
style={{ display: 'block', width: '100%' }}
>
<script type="text/wxtag-template" dangerouslySetInnerHTML={{ __html: htmlContent }} />
</wx-open-launch-weapp>
</Box>
);
}
return null;
};
export default WxOpenLaunchWeapp;

View File

@@ -0,0 +1,118 @@
/**
* 微信环境检测 Hook
* 用于判断当前运行环境,选择合适的小程序跳转方式
*/
import { useMemo } from 'react';
/**
* 跳转方式枚举
*/
export const LAUNCH_METHOD = {
OPEN_TAG: 'openTag', // 微信内使用开放标签
URL_SCHEME: 'urlScheme', // 外部浏览器使用 URL Scheme
QR_CODE: 'qrCode', // PC 端显示小程序码
};
/**
* 检测是否在微信浏览器内
*/
export const isWeChatBrowser = () => {
if (typeof window === 'undefined') return false;
const ua = navigator.userAgent.toLowerCase();
return ua.includes('micromessenger');
};
/**
* 检测是否为移动端设备
*/
export const isMobileDevice = () => {
if (typeof window === 'undefined') return false;
const ua = navigator.userAgent;
return /iPhone|iPad|iPod|Android|webOS|BlackBerry|IEMobile|Opera Mini/i.test(ua);
};
/**
* 检测是否为 iOS 设备
*/
export const isIOSDevice = () => {
if (typeof window === 'undefined') return false;
const ua = navigator.userAgent;
return /iPhone|iPad|iPod/i.test(ua);
};
/**
* 检测是否为 Android 设备
*/
export const isAndroidDevice = () => {
if (typeof window === 'undefined') return false;
const ua = navigator.userAgent;
return /Android/i.test(ua);
};
/**
* 获取微信版本号
* @returns {string|null} 版本号字符串,如 "7.0.12"
*/
export const getWeChatVersion = () => {
if (typeof window === 'undefined') return null;
const ua = navigator.userAgent.toLowerCase();
const match = ua.match(/micromessenger\/(\d+\.\d+\.\d+)/i);
return match ? match[1] : null;
};
/**
* 检测微信版本是否支持开放标签
* 开放标签需要微信 7.0.12 及以上版本
*/
export const isOpenTagSupported = () => {
const version = getWeChatVersion();
if (!version) return false;
const [major, minor, patch] = version.split('.').map(Number);
// 7.0.12 及以上支持
if (major > 7) return true;
if (major === 7) {
if (minor > 0) return true;
if (minor === 0 && patch >= 12) return true;
}
return false;
};
/**
* 微信环境检测 Hook
* @returns {Object} 环境信息对象
*/
export const useWechatEnvironment = () => {
const environment = useMemo(() => {
const isWechat = isWeChatBrowser();
const isMobile = isMobileDevice();
const isIOS = isIOSDevice();
const isAndroid = isAndroidDevice();
const wechatVersion = getWeChatVersion();
const openTagSupported = isOpenTagSupported();
// 确定跳转方式
let launchMethod;
if (isWechat && openTagSupported) {
launchMethod = LAUNCH_METHOD.OPEN_TAG;
} else if (isMobile) {
launchMethod = LAUNCH_METHOD.URL_SCHEME;
} else {
launchMethod = LAUNCH_METHOD.QR_CODE;
}
return {
isWechat, // 是否在微信内
isMobile, // 是否移动端
isIOS, // 是否 iOS
isAndroid, // 是否 Android
wechatVersion, // 微信版本
openTagSupported, // 是否支持开放标签
launchMethod, // 推荐的跳转方式
};
}, []);
return environment;
};
export default useWechatEnvironment;

View File

@@ -0,0 +1,113 @@
/**
* 小程序跳转组件
* 自动根据环境选择合适的跳转方式:
* - 微信内:使用开放标签 wx-open-launch-weapp
* - 外部浏览器(移动端):使用 URL Scheme
* - PC 浏览器:显示小程序码
*/
import React from 'react';
import { Box } from '@chakra-ui/react';
import useWechatEnvironment, { LAUNCH_METHOD } from './hooks/useWechatEnvironment';
import WxOpenLaunchWeapp from './WxOpenLaunchWeapp';
import UrlSchemeLauncher from './UrlSchemeLauncher';
import QRCodeDisplay from './QRCodeDisplay';
/**
* 小程序跳转统一入口组件
*
* @param {Object} props
* @param {string} [props.path] - 小程序页面路径,如 "/pages/index/index"
* @param {string} [props.query] - 页面参数,如 "id=123&from=h5"
* @param {React.ReactNode} props.children - 按钮内容
* @param {string} [props.qrCodeUrl] - PC 端显示的小程序码图片 URL
* @param {Function} [props.onSuccess] - 跳转成功回调
* @param {Function} [props.onError] - 跳转失败回调
* @param {Object} [props.buttonProps] - 传递给按钮的属性
* @param {Object} [props.buttonStyle] - 微信开放标签按钮样式
* @param {string} [props.forceLaunchMethod] - 强制使用指定的跳转方式(调试用)
*
* @example
* // 基础用法
* <MiniProgramLauncher path="/pages/pay/index" query="order_id=123">
* 去小程序支付
* </MiniProgramLauncher>
*
* @example
* // 带回调
* <MiniProgramLauncher
* path="/pages/index/index"
* onSuccess={() => console.log('跳转成功')}
* onError={(err) => console.error('跳转失败', err)}
* >
* 打开小程序
* </MiniProgramLauncher>
*/
const MiniProgramLauncher = ({
path = '',
query = '',
children = '打开小程序',
qrCodeUrl,
onSuccess,
onError,
buttonProps = {},
buttonStyle = {},
forceLaunchMethod,
}) => {
const { launchMethod } = useWechatEnvironment();
// 使用强制指定的方式或自动检测的方式
const method = forceLaunchMethod || launchMethod;
// 根据环境选择组件
const renderLauncher = () => {
switch (method) {
case LAUNCH_METHOD.OPEN_TAG:
return (
<WxOpenLaunchWeapp
path={path}
query={query}
onLaunch={onSuccess}
onError={onError}
buttonStyle={buttonStyle}
>
{children}
</WxOpenLaunchWeapp>
);
case LAUNCH_METHOD.URL_SCHEME:
return (
<UrlSchemeLauncher
path={path}
query={query}
onSuccess={onSuccess}
onError={onError}
buttonProps={buttonProps}
>
{children}
</UrlSchemeLauncher>
);
case LAUNCH_METHOD.QR_CODE:
default:
return (
<QRCodeDisplay
path={path}
qrCodeUrl={qrCodeUrl}
buttonProps={buttonProps}
>
{children}
</QRCodeDisplay>
);
}
};
return <Box>{renderLauncher()}</Box>;
};
// 导出子组件和工具函数,方便单独使用
export { default as WxOpenLaunchWeapp } from './WxOpenLaunchWeapp';
export { default as UrlSchemeLauncher } from './UrlSchemeLauncher';
export { default as QRCodeDisplay } from './QRCodeDisplay';
export { default as useWechatEnvironment, LAUNCH_METHOD } from './hooks/useWechatEnvironment';
export default MiniProgramLauncher;