From 37c46dbaaf20bc01b9fdedfe9e22afacecd731cc Mon Sep 17 00:00:00 2001 From: hewenguang Date: Thu, 30 Oct 2025 16:45:32 +0800 Subject: [PATCH 1/4] feat(pay): add pay --- src/App.tsx | 6 +++ src/hooks/use-device.ts | 13 ++++++ src/page/pay/index.tsx | 73 ++++++++++++++++++++++++++++++++++ src/page/pay/qrcode.tsx | 32 +++++++++++++++ src/page/user/wechat/index.tsx | 10 ++--- 5 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 src/hooks/use-device.ts create mode 100644 src/page/pay/index.tsx create mode 100644 src/page/pay/qrcode.tsx diff --git a/src/App.tsx b/src/App.tsx index 46068f6c..87c331ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,8 @@ import AppContext from '@/hooks/app-context'; import Layout from '@/layout'; import Error from '@/layout/error'; +const WeixinPay = lazy(() => import('@/page/pay')); + const ChatPage = lazy(() => import('@/page/chat')); const ChatHomePage = lazy(() => import('@/page/chat/home')); const ChatConversationPage = lazy(() => import('@/page/chat/conversation')); @@ -88,6 +90,10 @@ const router = createBrowserRouter([ path: 'invite/:namespace_id/:invitation_id', element: , }, + { + path: 'single/pay', + element: , + }, { path: ':namespace_id', element: , diff --git a/src/hooks/use-device.ts b/src/hooks/use-device.ts new file mode 100644 index 00000000..edc54c81 --- /dev/null +++ b/src/hooks/use-device.ts @@ -0,0 +1,13 @@ +import isMobile from 'ismobilejs'; + +export function useDevice() { + const userAgent = navigator.userAgent.toLowerCase(); + const isPhone = isMobile(userAgent).phone; + const isWeChat = userAgent.includes('micromessenger'); + + return { + desktop: !isPhone, + mobile: isPhone, + wechat: isWeChat, + }; +} diff --git a/src/page/pay/index.tsx b/src/page/pay/index.tsx new file mode 100644 index 00000000..5f1ef1db --- /dev/null +++ b/src/page/pay/index.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { useDevice } from '@/hooks/use-device'; +import { http } from '@/lib/request'; + +import { QrCode } from './qrcode'; + +export default function WeixinPay() { + const [code, onCode] = useState(''); + const { mobile, wechat } = useDevice(); + const handleTransactions = () => { + const productId = 'product-xxx'; + let type: 'native' | 'jsapi' | 'h5' = mobile ? 'h5' : 'native'; + if (wechat) { + type = 'jsapi'; + } + http + .post(`/pay/weixin/transactions/${type}/${productId}`) + .then(response => { + const { orderId, ...args } = response; + if (type === 'jsapi') { + function onBridgeReady() { + WeixinJSBridge.invoke('getBrandWCPayRequest', args, function () { + http + .get(`/pay/weixin/query/${orderId}`) + .then(() => { + toast('支付成功', { position: 'bottom-right' }); + }) + .catch(error => { + toast(error.message, { position: 'bottom-right' }); + }); + }); + } + if (typeof WeixinJSBridge == 'undefined') { + if (document.addEventListener) { + document.addEventListener( + 'WeixinJSBridgeReady', + onBridgeReady, + false + ); + // @ts-ignore + } else if (document.attachEvent) { + // @ts-ignore + document.attachEvent('WeixinJSBridgeReady', onBridgeReady); + // @ts-ignore + document.attachEvent('onWeixinJSBridgeReady', onBridgeReady); + } + } else { + onBridgeReady(); + } + return; + } + if (type === 'h5') { + location.href = `${args.h5_url}&redirect_url=${encodeURIComponent(location.href + '?return_from_pay=1')}`; + return; + } + if (type === 'native') { + onCode(args.code_url); + return; + } + }); + }; + + if (code) { + return ; + } + + return ; +} + +declare let WeixinJSBridge: any; diff --git a/src/page/pay/qrcode.tsx b/src/page/pay/qrcode.tsx new file mode 100644 index 00000000..801502a6 --- /dev/null +++ b/src/page/pay/qrcode.tsx @@ -0,0 +1,32 @@ +import QRCode from 'qrcode'; +import { useEffect, useState } from 'react'; + +interface IProps { + data: string; +} + +export function QrCode(props: IProps) { + const { data } = props; + const [url, onUrl] = useState(''); + + useEffect(() => { + QRCode.toDataURL(data, { + width: 134, + margin: 0, + color: { + dark: '#000000', + light: '#FFFFFF', + }, + }) + .then(onUrl) + .catch(err => { + console.error('Error generating QR code:', err); + }); + }, [data]); + + return ( +
+ QR Code +
+ ); +} diff --git a/src/page/user/wechat/index.tsx b/src/page/user/wechat/index.tsx index a7451ccc..829eb8ee 100644 --- a/src/page/user/wechat/index.tsx +++ b/src/page/user/wechat/index.tsx @@ -1,8 +1,8 @@ -import isMobile from 'ismobilejs'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Button } from '@/components/button'; +import { useDevice } from '@/hooks/use-device'; import { http } from '@/lib/request'; import { WeChatIcon } from './icon'; @@ -14,14 +14,12 @@ interface IProps { export default function WeChat(props: IProps) { const { onScan } = props; const { t } = useTranslation(); - const userAgent = navigator.userAgent.toLowerCase(); - const isPhone = isMobile(userAgent).phone; - const isWeChat = userAgent.includes('micromessenger'); + const { mobile, wechat } = useDevice(); const alertDisableWeChatLogin = () => { toast(t('login.wechat_disabled'), { position: 'bottom-right' }); }; const loginWithWeChat = () => { - if (isWeChat) { + if (wechat) { http .get('/wechat/auth-url') .then(authUrl => { @@ -35,7 +33,7 @@ export default function WeChat(props: IProps) { } }; - if (isPhone && !isWeChat) { + if (mobile && !wechat) { return ( ; -} - -declare let WeixinJSBridge: any; diff --git a/src/page/sidebar/index.tsx b/src/page/sidebar/index.tsx index fbf36916..9078798a 100644 --- a/src/page/sidebar/index.tsx +++ b/src/page/sidebar/index.tsx @@ -1,4 +1,5 @@ import { Sidebar, SidebarHeader, SidebarRail } from '@/components/ui/sidebar'; +import { UpgradeButton } from '@/page/upgrade/button'; import Content from './content'; import { FooterSidebar } from './footer'; @@ -31,6 +32,7 @@ export default function MainSidebar() {
+ navigate('/upgrade')}> + 升级帐户 + + ); +} diff --git a/src/page/upgrade/index.tsx b/src/page/upgrade/index.tsx new file mode 100644 index 00000000..9379c119 --- /dev/null +++ b/src/page/upgrade/index.tsx @@ -0,0 +1,26 @@ +import { ArrowLeft } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +import { Button } from '@/components/ui/button'; + +import { Product } from './product'; + +export default function UpgradePage() { + const navigate = useNavigate(); + + return ( +
+
+ +
+
+

升级帐户

+

选择适合您的套餐,解锁更多功能

+
+ +
+ ); +} diff --git a/src/page/upgrade/payment.tsx b/src/page/upgrade/payment.tsx new file mode 100644 index 00000000..635dcb4c --- /dev/null +++ b/src/page/upgrade/payment.tsx @@ -0,0 +1,318 @@ +import { ArrowLeft, Check } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { useDevice } from '@/hooks/use-device'; +import { Order, OrderStatus } from '@/interface'; +import { http } from '@/lib/request'; + +import { QrCode } from './qrcode'; + +type PaymentMethod = 'wechat' | 'alipay'; + +interface PaymentOption { + id: PaymentMethod; + name: string; + description: string; +} + +const paymentOptions: PaymentOption[] = [ + { + id: 'wechat', + name: '微信支付', + description: '使用微信扫码或在微信内支付', + }, + { + id: 'alipay', + name: '支付宝', + description: '使用支付宝扫码或跳转支付', + }, +]; + +export default function Payment() { + const navigate = useNavigate(); + const codeOrderId = useRef(''); + const [code, setCode] = useState(''); + const { mobile, wechat } = useDevice(); + const [searchParams] = useSearchParams(); + const orderType = searchParams.get('type'); + const productId = searchParams.get('productId'); + const orderIdFromPay = searchParams.get('orderId'); + const [selectedMethod, setSelectedMethod] = useState( + null + ); + const queryOrder = ( + type: string, + orderId: string, + isPolling: boolean = false + ): Promise => { + return http + .get(`/pay/${type}/query/${orderId}`) + .then((order: Order) => { + if (order.status === OrderStatus.PAID) { + toast('支付成功', { position: 'bottom-right' }); + setTimeout(() => { + navigate('/upgrade'); + }, 1000); + return true; + } else if (!isPolling) { + toast('支付失败', { position: 'bottom-right' }); + } + return false; + }) + .catch(error => { + if (!isPolling) { + toast(error.message, { position: 'bottom-right' }); + } + return false; + }); + }; + const handlePayment = () => { + if (!selectedMethod) { + toast.error('请选择支付方式', { position: 'bottom-right' }); + return; + } + if (!productId) { + toast.error('产品信息缺失', { position: 'bottom-right' }); + return; + } + if (selectedMethod === 'wechat') { + handleWechatPay(); + } else if (selectedMethod === 'alipay') { + handleAlipay(); + } + }; + const handleWechatPay = () => { + let type: 'native' | 'jsapi' | 'h5' = mobile ? 'h5' : 'native'; + if (wechat) { + type = 'jsapi'; + } + http + .post(`/pay/weixin/transactions/${type}/${productId}`, { mute: true }) + .then(response => { + const { orderId, ...args } = response; + if (type === 'jsapi') { + function onBridgeReady() { + WeixinJSBridge.invoke('getBrandWCPayRequest', args, function () { + queryOrder('weixin', orderId); + }); + } + if (typeof WeixinJSBridge == 'undefined') { + if (document.addEventListener) { + document.addEventListener( + 'WeixinJSBridgeReady', + onBridgeReady, + false + ); + // @ts-ignore + } else if (document.attachEvent) { + // @ts-ignore + document.attachEvent('WeixinJSBridgeReady', onBridgeReady); + // @ts-ignore + document.attachEvent('onWeixinJSBridgeReady', onBridgeReady); + } + } else { + onBridgeReady(); + } + return; + } + if (type === 'h5') { + location.href = `${args.h5_url}&redirect_url=${encodeURIComponent(location.href + '&type=weixin&orderId=' + orderId)}`; + return; + } + if (type === 'native') { + setCode(args.code_url); + codeOrderId.current = orderId; + return; + } + }) + .catch(err => { + if (err.response.data.code === 'wechat_not_bound') { + toast('请完成授权后再次尝试支付,开始授权中...', { + position: 'bottom-right', + }); + setTimeout(() => { + http + .get('/wechat/auth-url') + .then(authUrl => { + location.href = authUrl; + }) + .catch(error => { + toast.error(error.message, { position: 'bottom-right' }); + }); + }, 2000); + } else { + toast(err.response.data.message, { position: 'bottom-right' }); + } + }); + }; + const handleAlipay = () => { + const type = mobile ? 'h5' : 'native'; + http + .post( + `/pay/alipay/transactions/${type}/${productId}?returnUrl=${encodeURIComponent(location.href + '&type=alipay')}` + ) + .then(response => { + location.href = response.url; + }) + .catch(error => { + toast.error(error.message || '支付失败,请稍后重试', { + position: 'bottom-right', + }); + }); + }; + const handleBack = () => { + navigate(-1); + }; + + useEffect(() => { + if (!orderIdFromPay || !orderType) { + return; + } + queryOrder(orderType, orderIdFromPay); + }, [orderIdFromPay, orderType]); + + useEffect(() => { + if (!code || !codeOrderId.current) { + return; + } + + const POLLING_INTERVAL = 2000; + const MAX_POLLING_TIME = 5 * 60 * 1000; + const startTime = Date.now(); + let timerId: NodeJS.Timeout; + + const poll = async () => { + if (Date.now() - startTime > MAX_POLLING_TIME) { + clearInterval(timerId); + toast('支付超时,请重新发起支付', { position: 'bottom-right' }); + return; + } + + const shouldStop = await queryOrder('weixin', codeOrderId.current, true); + if (shouldStop) { + clearInterval(timerId); + } + }; + + timerId = setInterval(poll, POLLING_INTERVAL); + + return () => { + if (timerId) { + clearInterval(timerId); + } + }; + }, [code]); + + if (orderIdFromPay) { + return ( +
+ + + 查询中 + 正在查询订单状态 + + +
+ ); + } + + if (code) { + return ( +
+ + + 扫码支付 + + 请使用微信扫描二维码完成支付 + + + + + + +
+ ); + } + + if (!productId) { + return ( +
+ + + 错误 + 产品信息缺失 + + + + + +
+ ); + } + + return ( +
+
+ +
+ + + 选择支付方式 + 请选择您方便的支付方式完成购买 + + +
+ {paymentOptions.map(option => ( + setSelectedMethod(option.id)} + > + +
+

{option.name}

+

+ {option.description} +

+
+ {selectedMethod === option.id && ( + + )} +
+
+ ))} +
+ +
+
+
+ ); +} + +declare let WeixinJSBridge: any; diff --git a/src/page/upgrade/product.tsx b/src/page/upgrade/product.tsx new file mode 100644 index 00000000..d9752e8e --- /dev/null +++ b/src/page/upgrade/product.tsx @@ -0,0 +1,150 @@ +import { Check } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + +interface ProductPlan { + id: string; + name: string; + description: string; + price: string; + period: string; + features: string[]; + popular?: boolean; + buttonText: string; +} + +const products: ProductPlan[] = [ + { + id: '022cbb4d-19ea-41bb-a3cd-ce7d5b61e14a', + name: '基础版', + description: '适合个人用户和小型项目', + price: '¥99', + period: '/月', + buttonText: '选择基础版', + features: [ + '10GB 存储空间', + '基础功能访问', + '邮件支持', + '每月 1000 次 API 调用', + '单用户使用', + ], + }, + { + id: '03b8f767-2fb4-4326-9121-d0ba8bb97d42', + name: '专业版', + description: '适合成长中的团队和企业', + price: '¥299', + period: '/月', + buttonText: '选择专业版', + popular: true, + features: [ + '100GB 存储空间', + '全部高级功能', + '优先邮件和在线支持', + '每月 10000 次 API 调用', + '最多 5 个用户', + '自定义域名', + '高级分析报告', + ], + }, + { + id: '0adafefa-92c2-4d91-ac3c-d7743f6c6809', + name: '企业版', + description: '适合大型团队和企业', + price: '¥999', + period: '/月', + buttonText: '选择企业版', + features: [ + '无限存储空间', + '全部功能 + 定制化', + '7x24 专属客服支持', + '无限 API 调用', + '无限用户', + '自定义域名', + '高级分析报告', + 'SLA 保障', + '专属客户经理', + ], + }, +]; + +export function Product() { + const navigate = useNavigate(); + + const handlePurchase = (productId: string) => { + navigate(`/upgrade/payment?productId=${productId}`); + }; + + return ( +
+
+ {products.map(product => ( + + {product.popular && ( +
+ + 最受欢迎 + +
+ )} + + + {product.name} + + {product.description} + + + + +
+ {product.price} + + {product.period} + +
+ +
    + {product.features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+
+ + + + +
+ ))} +
+ +
+

所有套餐均支持随时取消,按月计费无需长期承诺

+
+
+ ); +} diff --git a/src/page/pay/qrcode.tsx b/src/page/upgrade/qrcode.tsx similarity index 100% rename from src/page/pay/qrcode.tsx rename to src/page/upgrade/qrcode.tsx From 2f897b1dbdf50dc3201460f61e5ec1a5bfc0b020 Mon Sep 17 00:00:00 2001 From: hewenguang Date: Wed, 12 Nov 2025 17:31:41 +0800 Subject: [PATCH 3/4] fix(comment): fix comments From 916377eb0f6d08eef78f93efbf7ea330e1ed1784 Mon Sep 17 00:00:00 2001 From: hewenguang Date: Wed, 12 Nov 2025 19:15:57 +0800 Subject: [PATCH 4/4] fix(comment): fix comments --- src/page/upgrade/payment.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/page/upgrade/payment.tsx b/src/page/upgrade/payment.tsx index 635dcb4c..c1273d9a 100644 --- a/src/page/upgrade/payment.tsx +++ b/src/page/upgrade/payment.tsx @@ -99,11 +99,11 @@ export default function Payment() { http .post(`/pay/weixin/transactions/${type}/${productId}`, { mute: true }) .then(response => { - const { orderId, ...args } = response; + const { order_id, ...args } = response; if (type === 'jsapi') { function onBridgeReady() { WeixinJSBridge.invoke('getBrandWCPayRequest', args, function () { - queryOrder('weixin', orderId); + queryOrder('weixin', order_id); }); } if (typeof WeixinJSBridge == 'undefined') { @@ -126,12 +126,12 @@ export default function Payment() { return; } if (type === 'h5') { - location.href = `${args.h5_url}&redirect_url=${encodeURIComponent(location.href + '&type=weixin&orderId=' + orderId)}`; + location.href = `${args.h5_url}&redirect_url=${encodeURIComponent(location.href + '&type=weixin&orderId=' + order_id)}`; return; } if (type === 'native') { + codeOrderId.current = order_id; setCode(args.code_url); - codeOrderId.current = orderId; return; } }) @@ -182,7 +182,7 @@ export default function Payment() { }, [orderIdFromPay, orderType]); useEffect(() => { - if (!code || !codeOrderId.current) { + if (!code) { return; }