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 7be5e3b9e1
commit 154bb76212
8 changed files with 1200 additions and 0 deletions

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;