Ver Fonte

perf: 主页点击套餐跳转到购买页时自动选中点击的套餐

BaiLuoYan há 3 semanas atrás
pai
commit
d5894dbf43

+ 2 - 17
src/pages/home/components/Features.tsx

@@ -1,4 +1,3 @@
-import { useEffect, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { Icon } from '@iconify/react';
@@ -6,26 +5,12 @@ import { Icon } from '@iconify/react';
 import featureCoverage from '@/assets/iconify/multi-color/feature-coverage.svg';
 import featureEncryption from '@/assets/iconify/multi-color/feature-encryption.svg';
 import featureSpeed from '@/assets/iconify/multi-color/feature-speed.svg';
+import { useScrollToCenter } from '../useScrollToCenter';
 import Wrapper from './Wrapper';
 
 export function Features() {
     const { t } = useTranslation();
-    const scrollRef = useRef<HTMLDivElement>(null);
-
-    useEffect(() => {
-        const el = scrollRef.current;
-        if (!el) return;
-        const scrollToCenter = () => {
-            const { scrollWidth, clientWidth } = el;
-            if (scrollWidth > clientWidth) {
-                el.scrollLeft = (scrollWidth - clientWidth) / 2;
-            }
-        };
-        scrollToCenter();
-        const observer = new ResizeObserver(scrollToCenter);
-        observer.observe(el);
-        return () => observer.disconnect();
-    }, []);
+    const scrollRef = useScrollToCenter();
 
     const items = [
         {

+ 1 - 1
src/pages/home/components/Pricing/PlanCard.tsx

@@ -37,7 +37,7 @@ const PlanCard = memo(
         const { t } = useTranslation();
         return (
             <div
-                className={`relative box-border cursor-pointer transition-all bg-white/10 border-2 flex-col-c p-5 sm:p-8 w-full sm:w-[406px] rounded-2xl gap-2 sm:gap-3 shrink-0 ${
+                className={`relative box-border cursor-pointer transition-all bg-white/10 border-2 flex-col-c p-5 sm:p-8 w-full sm:w-[356px] lg:w-[406px] min-w-[180px] rounded-2xl gap-2 sm:gap-3 shrink-0 ${
                     isSelected ? 'border-[#0FA4E9]' : 'border-white/10'
                 }`}
                 onClick={onSelected}

+ 7 - 18
src/pages/home/components/Pricing/index.tsx

@@ -1,31 +1,17 @@
-import { useEffect, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router-dom';
 
+import { useScrollToCenter } from '../../useScrollToCenter';
 import PlanCard from './PlanCard';
 import { useService } from './useService';
 import Wrapper from '../Wrapper';
+import { encryptUrlParams } from '@/utils/requestCrypto';
 
 export function Pricing() {
     const { t } = useTranslation();
     const navigate = useNavigate();
     const { plans, selectedPlanId, setSelectedPlanId } = useService();
-    const scrollRef = useRef<HTMLDivElement>(null);
-
-    useEffect(() => {
-        const el = scrollRef.current;
-        if (!el) return;
-        const scrollToCenter = () => {
-            const { scrollWidth, clientWidth } = el;
-            if (scrollWidth > clientWidth) {
-                el.scrollLeft = (scrollWidth - clientWidth) / 2;
-            }
-        };
-        scrollToCenter();
-        const observer = new ResizeObserver(scrollToCenter);
-        observer.observe(el);
-        return () => observer.disconnect();
-    }, []);
+    const scrollRef = useScrollToCenter();
 
     return (
         <section className="w-full pt-7 sm:pt-16 lg:pt-[176px]">
@@ -55,7 +41,10 @@ export function Pricing() {
                             tagType={plan.tagType}
                             isSelected={selectedPlanId === plan.id}
                             onSelected={() => setSelectedPlanId(plan.id)}
-                            onGetStarted={() => navigate(`/pricing?planId=${plan.id}`)}
+                            onGetStarted={async () => {
+                                const d = await encryptUrlParams({ planId: plan.id });
+                                navigate(`/pricing?d=${d}`);
+                            }}
                         />
                     ))}
                 </Wrapper>

+ 26 - 0
src/pages/home/useScrollToCenter.ts

@@ -0,0 +1,26 @@
+import { useEffect, useRef } from 'react';
+
+/**
+ * 横向滚动容器超出视口时,默认滚动到中间;尺寸变化时重新居中。
+ * 返回 ref,挂到可横向滚动的容器上。
+ */
+export function useScrollToCenter() {
+    const scrollRef = useRef<HTMLDivElement>(null);
+
+    useEffect(() => {
+        const el = scrollRef.current;
+        if (!el) return;
+        const scrollToCenter = () => {
+            const { scrollWidth, clientWidth } = el;
+            if (scrollWidth > clientWidth) {
+                el.scrollLeft = (scrollWidth - clientWidth) / 2;
+            }
+        };
+        scrollToCenter();
+        const observer = new ResizeObserver(scrollToCenter);
+        observer.observe(el);
+        return () => observer.disconnect();
+    }, []);
+
+    return scrollRef;
+}

+ 2 - 10
src/pages/pricing/index.tsx

@@ -1,4 +1,4 @@
-import { memo, useEffect } from 'react';
+import { memo } from 'react';
 
 import { Form } from 'antd';
 import { useTranslation } from 'react-i18next';
@@ -83,21 +83,13 @@ const Pricing = memo(() => {
     const { isMobile } = useResponsive();
     const [form] = Form.useForm<{ planId: string; payMethod: string }>();
     const { plans, payMethods } = useService();
-    const { handlePayNow } = useAction();
+    const { handlePayNow } = useAction({ form, plans });
 
     const planId = Form.useWatch('planId', form);
     const payMethod = Form.useWatch('payMethod', form);
     const selectedPlan = plans.find((p) => p.id === planId) ?? null;
     const selectedPayMethod = payMethod ?? null;
 
-    useEffect(() => {
-        if (plans.length === 0) return;
-        const defaultPlan = plans.find((p) => p.isDefault);
-        if (defaultPlan) {
-            form.setFieldsValue({ planId: defaultPlan.id });
-        }
-    }, [plans, form]);
-
     const onFinish = (values: { planId: string; payMethod: string }) => {
         const plan = plans.find((p) => p.id === values.planId);
         if (!plan || !values.payMethod) return;

+ 35 - 3
src/pages/pricing/useAction.tsx

@@ -1,15 +1,18 @@
-import { useCallback } from 'react';
+import { useCallback, useEffect } from 'react';
 
+import { FormInstance } from 'antd';
 import { Trans, useTranslation } from 'react-i18next';
+import { useSearchParams } from 'react-router-dom';
 
 import LoginForm from '@/components/LoginForm';
-import { message } from '@/utils/antdAppInstance';
 import { PayUrlShowType } from '@/defines';
 import { useAppUrls } from '@/hooks/useAppUrls';
 import { dialogModel } from '@/models/dialogModel';
 import { fetchGetUserConfig, fetchPayOrderCreate } from '@/services/config';
 import { getToken } from '@/utils/authUtils';
+import { decryptUrlParams } from '@/utils/requestCrypto';
 import { currentUnixTimestamp } from '@/utils/timeUtils';
+import { message } from '@/utils/antdAppInstance';
 
 import { PayQrContent } from './components/OrderSummary/PayQrContent';
 import { PayWaitingContent } from './components/OrderSummary/PayWaitingContent';
@@ -180,11 +183,40 @@ function useJumpToPayDialog() {
     );
 }
 
-export function useAction(): UseActionReturn {
+export interface UseActionParams {
+    form: FormInstance<{ planId: string; payMethod: string }>;
+    plans: Plan[];
+}
+
+export function useAction(params?: UseActionParams): UseActionReturn {
+    const form = params?.form;
+    const plans = params?.plans ?? [];
+    const [searchParams] = useSearchParams();
     const openLoginDialog = useLoginDialog();
     const openQrPayDialog = useQrPayDialog();
     const openJumpToPayDialog = useJumpToPayDialog();
 
+    useEffect(() => {
+        if (!form || plans.length === 0) return;
+        const encrypted = searchParams.get('d');
+        if (encrypted) {
+            decryptUrlParams<{ planId: string }>(encrypted).then((decrypted) => {
+                const id = decrypted?.planId;
+                if (id && plans.some((p) => p.id === id)) {
+                    form.setFieldsValue({ planId: id });
+                    return;
+                }
+                const defaultPlan = plans.find((p) => p.isDefault);
+                if (defaultPlan) form.setFieldsValue({ planId: defaultPlan.id });
+            });
+            return;
+        }
+        const defaultPlan = plans.find((p) => p.isDefault);
+        if (defaultPlan) {
+            form.setFieldsValue({ planId: defaultPlan.id });
+        }
+    }, [form, plans, searchParams]);
+
     const handlePayNow = useCallback(
         (selectedPlan: Plan, selectedPayMethod: string) => {
             const token = getToken();

+ 33 - 1
src/utils/requestCrypto.ts

@@ -1,4 +1,9 @@
-import { aesCbcDecryptBytes, aesCbcEncryptBytes, bytesBase64decode } from '@/utils/crypto';
+import {
+    aesCbcDecryptBytes,
+    aesCbcEncryptBytes,
+    bytesBase64decode,
+    bytesBase64encode,
+} from '@/utils/crypto';
 import {
     bytesToNumber,
     numberToBytesAt,
@@ -11,6 +16,10 @@ import globalConfig from '@/config';
 import { CompressMethod } from '@/config/types';
 
 import { compressBytes, decompressBytes, CompressFormat } from './compress';
+import { currentUnixTimestamp } from './timeUtils';
+
+export type JsonPrimitive = string | number | boolean | null;
+export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue | undefined };
 
 const TIMESTAMP_BYTES = 8;
 
@@ -88,6 +97,29 @@ export async function decryptResponsePayload(
     return { timestamp, data: rawBytes };
 }
 
+/**
+ * 加密 URL 参数
+ * @param params 参数对象
+ * @returns 加密后的 URL 参数
+ */
+export async function encryptUrlParams(params: JsonValue): Promise<string> {
+    const jsonString = typeof params === 'string' ? params : JSON.stringify(params);
+    const key = stringToBytes(globalConfig.security.requestEncryptionKey);
+    const dataBytes = stringToBytes(jsonString);
+    const encrypted = await encryptRequestPayload(
+        dataBytes,
+        currentUnixTimestamp(),
+        key,
+        globalConfig.security.compressMethod
+    );
+    return bytesBase64encode(encrypted, true);
+}
+
+/**
+ * 解密 URL 参数
+ * @param params 加密后的 URL 参数
+ * @returns 解密后的参数对象
+ */
 export async function decryptUrlParams<T = any>(params: string): Promise<T | null> {
     // const key = bytesBase64decode(globalConfig.security.requestEncryptionKey);
     const key = stringToBytes(globalConfig.security.requestEncryptionKey);