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

329
app.py
View File

@@ -333,6 +333,15 @@ WECHAT_OPEN_APPSECRET = 'eedef95b11787fd7ca7f1acc6c9061bc'
WECHAT_MP_APPID = 'wx8afd36f7c7b21ba0'
WECHAT_MP_APPSECRET = 'c3ec5a227ddb26ad8a1d4c55efa1cf86'
# 微信小程序配置H5 跳转小程序用)
WECHAT_MINIPROGRAM_APPID = 'wx0edeaab76d4fa414'
WECHAT_MINIPROGRAM_APPSECRET = os.environ.get('WECHAT_MINIPROGRAM_APPSECRET', '0d0c70084f05a8c1411f6b89da7e815d')
WECHAT_MINIPROGRAM_ORIGINAL_ID = 'gh_fd2fd8dd2fb5'
# Redis 缓存键前缀(微信 token
WECHAT_ACCESS_TOKEN_PREFIX = "wechat:access_token:"
WECHAT_JSAPI_TICKET_PREFIX = "wechat:jsapi_ticket:"
# 微信回调地址
WECHAT_REDIRECT_URI = 'https://valuefrontier.cn/api/auth/wechat/callback'
@@ -4751,6 +4760,326 @@ def unbind_wechat_account():
return jsonify({'error': '解绑失败,请重试'}), 500
# ============ H5 跳转小程序相关 API ============
def get_wechat_access_token_cached(appid, appsecret):
"""
获取微信 access_tokenRedis 缓存,支持多 Worker
Args:
appid: 微信 AppID公众号或小程序
appsecret: 对应的 AppSecret
Returns:
access_token 字符串,失败返回 None
"""
cache_key = f"{WECHAT_ACCESS_TOKEN_PREFIX}{appid}"
# 1. 尝试从 Redis 获取缓存
try:
cached = redis_client.get(cache_key)
if cached:
data = json.loads(cached)
# 提前 5 分钟刷新,避免临界问题
if data.get('expires_at', 0) > time.time() + 300:
print(f"[access_token] 使用缓存: appid={appid[:8]}...")
return data['token']
except Exception as e:
print(f"[access_token] Redis 读取失败: {e}")
# 2. 请求新 token
url = "https://api.weixin.qq.com/cgi-bin/token"
params = {
'grant_type': 'client_credential',
'appid': appid,
'secret': appsecret
}
try:
response = requests.get(url, params=params, timeout=10)
result = response.json()
if 'access_token' in result:
token = result['access_token']
expires_in = result.get('expires_in', 7200)
# 3. 存入 RedisTTL 比 token 有效期短 60 秒)
cache_data = {
'token': token,
'expires_at': time.time() + expires_in
}
redis_client.setex(
cache_key,
expires_in - 60,
json.dumps(cache_data)
)
print(f"[access_token] 获取成功: appid={appid[:8]}..., expires_in={expires_in}s")
return token
else:
print(f"[access_token] 获取失败: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}")
return None
except Exception as e:
print(f"[access_token] 请求异常: {e}")
return None
def get_jsapi_ticket_cached(appid, appsecret):
"""
获取 jsapi_ticketRedis 缓存)
用于 JS-SDK 签名
"""
cache_key = f"{WECHAT_JSAPI_TICKET_PREFIX}{appid}"
# 1. 尝试从缓存获取
try:
cached = redis_client.get(cache_key)
if cached:
data = json.loads(cached)
if data.get('expires_at', 0) > time.time() + 300:
print(f"[jsapi_ticket] 使用缓存")
return data['ticket']
except Exception as e:
print(f"[jsapi_ticket] Redis 读取失败: {e}")
# 2. 获取 access_token
access_token = get_wechat_access_token_cached(appid, appsecret)
if not access_token:
return None
# 3. 请求 jsapi_ticket
url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket"
params = {
'access_token': access_token,
'type': 'jsapi'
}
try:
response = requests.get(url, params=params, timeout=10)
result = response.json()
if result.get('errcode') == 0:
ticket = result['ticket']
expires_in = result.get('expires_in', 7200)
# 存入 Redis
cache_data = {
'ticket': ticket,
'expires_at': time.time() + expires_in
}
redis_client.setex(
cache_key,
expires_in - 60,
json.dumps(cache_data)
)
print(f"[jsapi_ticket] 获取成功, expires_in={expires_in}s")
return ticket
else:
print(f"[jsapi_ticket] 获取失败: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}")
return None
except Exception as e:
print(f"[jsapi_ticket] 请求异常: {e}")
return None
def generate_jssdk_signature(url, appid, appsecret):
"""
生成 JS-SDK 签名配置
Args:
url: 当前页面 URL不含 # 及其后的部分)
appid: 公众号 AppID
appsecret: 公众号 AppSecret
Returns:
签名配置字典,失败返回 None
"""
import hashlib
# 获取 jsapi_ticket
ticket = get_jsapi_ticket_cached(appid, appsecret)
if not ticket:
return None
# 生成签名参数
timestamp = int(time.time())
nonce_str = uuid.uuid4().hex
# 签名字符串(必须按字典序排序!)
sign_str = f"jsapi_ticket={ticket}&noncestr={nonce_str}&timestamp={timestamp}&url={url}"
# SHA1 签名
signature = hashlib.sha1(sign_str.encode('utf-8')).hexdigest()
return {
'appId': appid,
'timestamp': timestamp,
'nonceStr': nonce_str,
'signature': signature,
'jsApiList': ['updateAppMessageShareData', 'updateTimelineShareData'],
'openTagList': ['wx-open-launch-weapp']
}
@app.route('/api/wechat/jssdk-config', methods=['POST'])
def api_wechat_jssdk_config():
"""获取微信 JS-SDK 签名配置(用于开放标签)"""
try:
data = request.get_json() or {}
url = data.get('url')
if not url:
return jsonify({
'code': 400,
'message': '缺少必要参数 url',
'data': None
}), 400
# URL 校验:必须是允许的域名
from urllib.parse import urlparse
parsed = urlparse(url)
allowed_domains = ['valuefrontier.cn', 'www.valuefrontier.cn', 'localhost', '127.0.0.1']
if parsed.netloc.split(':')[0] not in allowed_domains:
return jsonify({
'code': 400,
'message': 'URL 域名不在允许范围内',
'data': None
}), 400
# URL 处理:移除 hash 部分
if '#' in url:
url = url.split('#')[0]
# 生成签名(使用公众号配置)
config = generate_jssdk_signature(
url=url,
appid=WECHAT_MP_APPID,
appsecret=WECHAT_MP_APPSECRET
)
if not config:
return jsonify({
'code': 500,
'message': '获取签名配置失败,请稍后重试',
'data': None
}), 500
return jsonify({
'code': 200,
'message': 'success',
'data': config
})
except Exception as e:
print(f"[JS-SDK Config] 异常: {e}")
import traceback
traceback.print_exc()
return jsonify({
'code': 500,
'message': '服务器内部错误',
'data': None
}), 500
@app.route('/api/miniprogram/url-scheme', methods=['POST'])
def api_miniprogram_url_scheme():
"""生成小程序 URL Scheme外部浏览器跳转小程序用"""
try:
# 频率限制
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
if client_ip:
client_ip = client_ip.split(',')[0].strip()
rate_key = f"rate_limit:urlscheme:{client_ip}"
current = redis_client.incr(rate_key)
if current == 1:
redis_client.expire(rate_key, 60)
if current > 30: # 每分钟最多 30 次
return jsonify({
'code': 429,
'message': '请求过于频繁,请稍后再试',
'data': None
}), 429
data = request.get_json() or {}
# 参数校验
path = data.get('path')
if path and not path.startswith('/'):
path = '/' + path # 自动补全 /
# 获取小程序 access_token
access_token = get_wechat_access_token_cached(
WECHAT_MINIPROGRAM_APPID,
WECHAT_MINIPROGRAM_APPSECRET
)
if not access_token:
return jsonify({
'code': 500,
'message': '获取访问令牌失败',
'data': None
}), 500
# 构建请求参数
wx_url = f"https://api.weixin.qq.com/wxa/generatescheme?access_token={access_token}"
expire_type = data.get('expire_type', 1)
expire_interval = min(data.get('expire_interval', 30), 30) # 最长30天
payload = {
"is_expire": expire_type == 1
}
# 跳转信息
if path or data.get('query'):
payload["jump_wxa"] = {}
if path:
payload["jump_wxa"]["path"] = path
if data.get('query'):
payload["jump_wxa"]["query"] = data.get('query')
# 有效期设置
if expire_type == 1:
if data.get('expire_time'):
payload["expire_time"] = data.get('expire_time')
else:
payload["expire_interval"] = expire_interval
response = requests.post(wx_url, json=payload, timeout=10)
result = response.json()
if result.get('errcode') == 0:
return jsonify({
'code': 200,
'message': 'success',
'data': {
'openlink': result['openlink'],
'expire_time': data.get('expire_time') or (int(time.time()) + expire_interval * 86400),
'created_at': datetime.utcnow().isoformat() + 'Z'
}
})
else:
print(f"[URL Scheme] 生成失败: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}")
return jsonify({
'code': 500,
'message': f"生成 URL Scheme 失败: {result.get('errmsg', '未知错误')}",
'data': None
}), 500
except Exception as e:
print(f"[URL Scheme] 异常: {e}")
import traceback
traceback.print_exc()
return jsonify({
'code': 500,
'message': '服务器内部错误',
'data': None
}), 500
# 评论模型
class EventComment(db.Model):
"""事件评论"""

View File

@@ -186,6 +186,9 @@
href="%PUBLIC_URL%/apple-icon.png"
/>
<link rel="shortcut icon" type="image/x-icon" href="./favicon.png" />
<!-- 微信 JS-SDK (用于 H5 跳转小程序的开放标签) -->
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
</head>
<body>
<noscript>

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;

View File

@@ -0,0 +1,98 @@
/**
* 小程序服务层
* 提供 H5 跳转小程序相关的 API 调用
*/
import axios from 'axios';
import { getApiBase } from '@utils/apiConfig';
const api = axios.create({
baseURL: getApiBase(),
timeout: 10000,
withCredentials: true,
});
/**
* 获取微信 JS-SDK 签名配置
* 用于在微信内 H5 使用开放标签 wx-open-launch-weapp
*
* @param {string} url - 当前页面 URL会自动移除 hash 部分)
* @returns {Promise<Object>} 签名配置对象
*/
export const getJsSdkConfig = async (url) => {
try {
const response = await api.post('/api/wechat/jssdk-config', { url });
if (response.data.code === 200) {
return response.data.data;
}
throw new Error(response.data.message || '获取签名配置失败');
} catch (error) {
console.error('[miniprogramService] getJsSdkConfig error:', error);
throw error;
}
};
/**
* 生成小程序 URL Scheme
* 用于外部浏览器拉起微信打开小程序
*
* @param {Object} options - 配置选项
* @param {string} [options.path] - 小程序页面路径,如 /pages/index/index
* @param {string} [options.query] - 页面参数,如 id=123&from=h5
* @param {number} [options.expireInterval=30] - 有效天数,最长 30 天
* @returns {Promise<Object>} 包含 openlink 的对象
*/
export const generateUrlScheme = async (options = {}) => {
try {
const { path, query, expireInterval = 30 } = options;
const response = await api.post('/api/miniprogram/url-scheme', {
path,
query,
expire_type: 1,
expire_interval: expireInterval,
});
if (response.data.code === 200) {
return response.data.data;
}
throw new Error(response.data.message || '生成 URL Scheme 失败');
} catch (error) {
console.error('[miniprogramService] generateUrlScheme error:', error);
throw error;
}
};
/**
* 打开 URL Scheme
* 尝试通过 URL Scheme 拉起微信
*
* @param {string} openlink - URL Scheme 链接
* @returns {boolean} 是否成功发起跳转
*/
export const openUrlScheme = (openlink) => {
if (!openlink) {
console.error('[miniprogramService] openUrlScheme: openlink is required');
return false;
}
try {
// 方式1直接跳转
window.location.href = openlink;
return true;
} catch (error) {
console.error('[miniprogramService] openUrlScheme error:', error);
return false;
}
};
// 注意getMiniprogramQRCode 功能暂未实现后端 API
// PC 端目前使用静态小程序码图片,如需动态生成请实现 /api/miniprogram/qrcode 接口
export default {
getJsSdkConfig,
generateUrlScheme,
openUrlScheme,
};