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_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_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):
|
||||
"""事件评论"""
|
||||
|
||||
@@ -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>
|
||||
|
||||
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