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:
329
app.py
329
app.py
@@ -333,6 +333,15 @@ WECHAT_OPEN_APPSECRET = 'eedef95b11787fd7ca7f1acc6c9061bc'
|
|||||||
WECHAT_MP_APPID = 'wx8afd36f7c7b21ba0'
|
WECHAT_MP_APPID = 'wx8afd36f7c7b21ba0'
|
||||||
WECHAT_MP_APPSECRET = 'c3ec5a227ddb26ad8a1d4c55efa1cf86'
|
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'
|
WECHAT_REDIRECT_URI = 'https://valuefrontier.cn/api/auth/wechat/callback'
|
||||||
|
|
||||||
@@ -4751,6 +4760,326 @@ def unbind_wechat_account():
|
|||||||
return jsonify({'error': '解绑失败,请重试'}), 500
|
return jsonify({'error': '解绑失败,请重试'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ============ H5 跳转小程序相关 API ============
|
||||||
|
|
||||||
|
def get_wechat_access_token_cached(appid, appsecret):
|
||||||
|
"""
|
||||||
|
获取微信 access_token(Redis 缓存,支持多 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. 存入 Redis(TTL 比 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_ticket(Redis 缓存)
|
||||||
|
用于 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}×tamp={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):
|
class EventComment(db.Model):
|
||||||
"""事件评论"""
|
"""事件评论"""
|
||||||
|
|||||||
@@ -186,6 +186,9 @@
|
|||||||
href="%PUBLIC_URL%/apple-icon.png"
|
href="%PUBLIC_URL%/apple-icon.png"
|
||||||
/>
|
/>
|
||||||
<link rel="shortcut icon" type="image/x-icon" href="./favicon.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
<noscript>
|
||||||
|
|||||||
143
src/components/MiniProgramLauncher/QRCodeDisplay.js
Normal file
143
src/components/MiniProgramLauncher/QRCodeDisplay.js
Normal 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;
|
||||||
193
src/components/MiniProgramLauncher/UrlSchemeLauncher.js
Normal file
193
src/components/MiniProgramLauncher/UrlSchemeLauncher.js
Normal 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;
|
||||||
203
src/components/MiniProgramLauncher/WxOpenLaunchWeapp.js
Normal file
203
src/components/MiniProgramLauncher/WxOpenLaunchWeapp.js
Normal 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;
|
||||||
118
src/components/MiniProgramLauncher/hooks/useWechatEnvironment.js
Normal file
118
src/components/MiniProgramLauncher/hooks/useWechatEnvironment.js
Normal 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;
|
||||||
113
src/components/MiniProgramLauncher/index.js
Normal file
113
src/components/MiniProgramLauncher/index.js
Normal 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;
|
||||||
98
src/services/miniprogramService.js
Normal file
98
src/services/miniprogramService.js
Normal 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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user