소스 검색

feat: protocal & cert

chenlei 1 년 전
부모
커밋
953a8a160a

+ 6 - 0
README.md

@@ -0,0 +1,6 @@
+## 依赖安装
+
+```bash
+# @dage模块依赖于本地npm
+yarn --registry http://192.168.20.245:4873/
+```

+ 1 - 0
package.json

@@ -37,6 +37,7 @@
     "@babel/runtime": "^7.7.7",
     "@dage/hooks": "^1.0.1",
     "@dage/service": "^1.0.3",
+    "@dage/utils": "^1.0.2",
     "@tarojs/components": "3.4.9",
     "@tarojs/plugin-framework-react": "3.4.9",
     "@tarojs/react": "3.4.9",

+ 24 - 0
src/api/index.ts

@@ -100,3 +100,27 @@ export const getRankingListApi = (params?: any) => {
     },
   });
 };
+
+export const getProtocolDetailApi = (m) => {
+  return requestByGet(`/api/show/getDictConfig/${m}`, undefined, {
+    meta: {
+      showLoading: true,
+    },
+  });
+};
+
+export const checkRedeemApi = () => {
+  return requestByGet(`/api/cms/game/redeem/check`, undefined, {
+    meta: {
+      showLoading: true,
+    },
+  });
+};
+
+export const getRedeemInfoApi = () => {
+  return requestByGet(`/api/cms/game/redeem/info`, undefined, {
+    meta: {
+      showLoading: true,
+    },
+  });
+};

+ 1 - 0
src/app.config.ts

@@ -10,6 +10,7 @@ export default defineAppConfig({
         "pages/iframe/index",
         "pages/portrait-iframe/index",
         "pages/museum/index",
+        "pages/protocol/index",
       ],
     },
   ],

+ 23 - 0
src/components/FooterProtocol/index.scss

@@ -0,0 +1,23 @@
+.footer-protocol {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin-top: 20px;
+  font-size: 23px;
+  color: #424a4a;
+  font-family: "Source Han Serif CN-Bold";
+
+  .primary {
+    color: #589498;
+  }
+  &__checkbox {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 23px;
+    height: 23px;
+    border-radius: 4px;
+    border: 2px solid #d5ddd7;
+  }
+}

+ 50 - 0
src/components/FooterProtocol/index.tsx

@@ -0,0 +1,50 @@
+import { View, Text, ITouchEvent } from "@tarojs/components";
+import Taro, { FC } from "@tarojs/taro";
+import classNames from "classnames";
+import React from "react";
+import { AtIcon } from "taro-ui";
+import "./index.scss";
+
+export interface FooterProtocolProps {
+  style?: React.CSSProperties;
+  checked: boolean;
+  setChecked: Function;
+}
+
+export const FooterProtocol: FC<FooterProtocolProps> = ({
+  style,
+  checked,
+  setChecked,
+}) => {
+  const handleClick = () => {
+    setChecked(!checked);
+  };
+
+  const goDetail = (e: ITouchEvent, id: number) => {
+    e.stopPropagation();
+
+    Taro.navigateTo({
+      url: `/subModule/pages/protocol/index?id=${id}`,
+    });
+  };
+
+  return (
+    <View
+      className={classNames("footer-protocol", { checked })}
+      style={style}
+      onClick={handleClick}
+    >
+      <View className="footer-protocol__checkbox">
+        {checked && <AtIcon value="check" size={8} color="#589498" />}
+      </View>
+      阅读并同意
+      <Text className="primary" onClick={(e) => goDetail(e, 2)}>
+        《用户服务协议》
+      </Text>
+      及
+      <Text className="primary" onClick={(e) => goDetail(e, 3)}>
+        《个人信息保护政策》
+      </Text>
+    </View>
+  );
+};

+ 13 - 17
src/components/PageSwiper/index.tsx

@@ -80,18 +80,16 @@ export const PageSwiper: FC = () => {
       await login();
     }
 
-    setVisitVisible(true);
-
-    // if (userInfo.invite === 0) {
-    //   setVisitVisible(true);
-    //   return;
-    // }
-
-    // Taro.navigateTo({
-    //   url:
-    //     "/subModule/pages/iframe/index?url=" +
-    //     encodeURIComponent(getSceneUrl()),
-    // });
+    if (userInfo.invite === 0) {
+      setVisitVisible(true);
+      return;
+    }
+
+    Taro.navigateTo({
+      url:
+        "/subModule/pages/iframe/index?url=" +
+        encodeURIComponent(getSceneUrl()),
+    });
   };
 
   const handleBgLoaded = () => {
@@ -152,11 +150,7 @@ export const PageSwiper: FC = () => {
             : bgRotateY[idx];
 
           return (
-            <SwiperItem
-              key={idx}
-              className="banner-swiper-item"
-              onClick={handleClick.bind(undefined, idx)}
-            >
+            <SwiperItem key={idx} className="banner-swiper-item">
               <View
                 className={classNames("banner-item", {
                   active: isActive,
@@ -164,6 +158,7 @@ export const PageSwiper: FC = () => {
                 style={{
                   transform: `rotateY(${rotateY}deg)`,
                 }}
+                onClick={handleClick.bind(undefined, idx)}
               >
                 <View
                   className="banner-item__img"
@@ -202,6 +197,7 @@ export const PageSwiper: FC = () => {
           muted
           loop
           controls={false}
+          enableProgressGesture={false}
           objectFit="cover"
           onLoadedMetaData={handleBgLoaded}
           onError={() => {

+ 1 - 0
src/components/Video/index.tsx

@@ -11,6 +11,7 @@ export const VideoWrap: FC<VideoWrapProps> = ({ onEnded, ...props }) => {
     <View className="video-wrap">
       <Video
         className="video-wrap__video"
+        enableProgressGesture={false}
         {...props}
         onEnded={() => onEnded()}
       />

BIN
src/images/icon_menu@2x-min.png


+ 2 - 9
src/pages/home/components/Menu/index.tsx

@@ -88,15 +88,8 @@ export const Menu: FC<MenuProps> = observer((props) => {
           },
           {
             label: "用户反馈",
-            method: () => {
-              Taro.showToast({
-                title: "暂未开发,敬请期待",
-                icon: "none",
-                duration: 3000,
-              });
-            },
-            // link: "/subModule/pages/feedback/index",
-            // needAuth: true,
+            link: "/subModule/pages/feedback/index",
+            needAuth: true,
           },
           {
             label: "爱心站",

+ 5 - 0
src/pages/home/components/SearchLayout/index.scss

@@ -126,4 +126,9 @@
         center / contain;
     }
   }
+  &__nomore {
+    text-align: center;
+    color: white;
+    line-height: 300px;
+  }
 }

+ 30 - 22
src/pages/home/components/SearchLayout/index.tsx

@@ -84,30 +84,38 @@ export const SearchLayout: FC<SearchLayoutProps> = (props) => {
             tabList={CITY_LIST}
             onClick={handleTabClick}
           >
-            {CITY_LIST.map((item, idx) => (
-              <AtTabsPane current={curTab} index={item.id}>
-                {(list[idx] || [])
-                  .filter((i) => (keyword ? fakeSearch(i.name) : true))
-                  .map((subItem) => (
-                    <View
-                      key={subItem.id}
-                      className="search-tab__item"
-                      onClick={props.openDetail.bind(undefined, subItem)}
-                    >
-                      <View className="search-tab__item-inner">
-                        <View className="search-tab__item-inner__label">
-                          {subItem.name}
-                        </View>
-                        <View className="limit-line">
-                          {subItem.description.slice(0, 50)}
+            {CITY_LIST.map((item, idx) => {
+              const _list = (list[idx] || []).filter((i) =>
+                keyword ? fakeSearch(i.name) : true
+              );
+
+              return (
+                <AtTabsPane current={curTab} index={item.id}>
+                  {_list.length ? (
+                    _list.map((subItem) => (
+                      <View
+                        key={subItem.id}
+                        className="search-tab__item"
+                        onClick={props.openDetail.bind(undefined, subItem)}
+                      >
+                        <View className="search-tab__item-inner">
+                          <View className="search-tab__item-inner__label">
+                            {subItem.name}
+                          </View>
+                          <View className="limit-line">
+                            {subItem.description.slice(0, 50)}
+                          </View>
                         </View>
-                      </View>
 
-                      <View className="search-tab__item__more" />
-                    </View>
-                  ))}
-              </AtTabsPane>
-            ))}
+                        <View className="search-tab__item__more" />
+                      </View>
+                    ))
+                  ) : (
+                    <View className="search-tab__nomore">暂无内容</View>
+                  )}
+                </AtTabsPane>
+              );
+            })}
           </AtTabs>
         </>
       )}

+ 2 - 0
src/pages/home/index.tsx

@@ -268,6 +268,7 @@ const HomePage: FC = () => {
             loop
             controls={false}
             objectFit="cover"
+            enableProgressGesture={false}
           />
         </SwiperItem>
       </Swiper>
@@ -283,6 +284,7 @@ const HomePage: FC = () => {
           muted
           controls={false}
           objectFit="cover"
+          enableProgressGesture={false}
           onEnded={handleLoaded}
           onError={handleLoaded}
         />

BIN
src/subModule/components/CertLayout/cert-min.png


+ 61 - 0
src/subModule/components/CertLayout/index.scss

@@ -0,0 +1,61 @@
+.cert {
+  .at-float-layout__overlay {
+    background: rgba(0, 0, 0, 0.65);
+    backdrop-filter: blur(30px);
+  }
+  .at-float-layout__container {
+    max-height: 100vh;
+    height: 100vh;
+    background-color: transparent;
+  }
+  .layout-body {
+    padding: 0;
+    // padding-bottom: constant(safe-area-inset-bottom);
+    // padding-bottom: env(safe-area-inset-bottom);
+    max-height: unset;
+    min-height: unset;
+    height: 100%;
+    box-sizing: border-box;
+  }
+  .layout-body__content {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    max-height: unset;
+    min-height: unset;
+    height: 100%;
+  }
+
+  &-wrap {
+    position: relative;
+    display: flex;
+    gap: 38px;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 85px 0 210px;
+    height: 100%;
+    box-sizing: border-box;
+    z-index: 2;
+  }
+
+  &__tips {
+    font-size: 31px;
+    color: white;
+  }
+
+  &__close {
+    position: absolute;
+    left: 50%;
+    bottom: 100px;
+    width: 80px;
+    height: 80px;
+    transform: translateX(-50%);
+    z-index: 2;
+  }
+
+  &__img {
+    width: 750px;
+    height: 516px;
+  }
+}

+ 185 - 0
src/subModule/components/CertLayout/index.tsx

@@ -0,0 +1,185 @@
+import { Canvas, Image, View } from "@tarojs/components";
+import Taro, { FC } from "@tarojs/taro";
+import { useEffect, useRef, useState } from "react";
+import { AtFloatLayout } from "taro-ui";
+import { AtFloatLayoutProps } from "taro-ui/types/float-layout";
+import CloseIcon from "../../../images/icon_back@2x-min.png";
+import "./index.scss";
+
+export interface CertLayoutProps extends AtFloatLayoutProps {
+  name: string;
+  date: string;
+}
+
+const system = Taro.getSystemInfoSync();
+
+export const CertLayout: FC<CertLayoutProps> = ({ name, date, ...props }) => {
+  const loaded = useRef(false);
+  const [imgPath, setImgPath] = useState("");
+
+  useEffect(() => {
+    if (props.isOpened && !loaded.current) init();
+  }, [props.isOpened]);
+
+  const loadFont = (url: string, family: string) => {
+    return new Promise((res, rej) => {
+      Taro.loadFontFace({
+        family,
+        source: `url('${url}')`,
+        // @ts-ignore
+        scopes: ["native"],
+        success: res,
+        fail: rej,
+      });
+    });
+  };
+
+  const init = async () => {
+    try {
+      Taro.showLoading({
+        title: "绘制中",
+      });
+
+      if (!loaded.current) {
+        await loadFont(
+          "https://houseoss.4dkankan.com/project/wx-csbwg-public/fonts/SourceHanSansSCBold.otf",
+          "SourceHanSansCN-Bold"
+        );
+        await loadFont(
+          "https://houseoss.4dkankan.com/project/wx-csbwg-public/fonts/SourceHanSerifCN-SemiBold.otf",
+          "SourceHanSerifCN-Bold"
+        );
+      }
+
+      Taro.createSelectorQuery()
+        .select("#certCanvas")
+        .fields({ node: true, size: true }, async (res) => {
+          const canvas = res.node;
+          const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
+
+          canvas.width = res.width * system.pixelRatio;
+          canvas.height = res.height * system.pixelRatio;
+
+          const bgInfo = await getTempImgPath(
+            "https://houseoss.4dkankan.com/project/wx-csbwg-public/images/cert-min.png"
+          );
+          const ratio = bgInfo.width / canvas.width;
+
+          const bgSource = await loadImg(canvas, bgInfo.path);
+
+          ctx.drawImage(bgSource, 0, 0, canvas.width, canvas.height);
+
+          ctxText(
+            ctx,
+            name.split("").join(" "),
+            `${14 * system.pixelRatio}px SourceHanSansCN-Bold`,
+            "center",
+            "#CFC49E",
+            canvas.width / 2,
+            342 / ratio
+          );
+
+          ctxText(
+            ctx,
+            date,
+            `${5.5 * system.pixelRatio}px SourceHanSerifCN-Bold`,
+            "left",
+            "#424A4A",
+            248 / ratio,
+            550 / ratio
+          );
+
+          await new Promise((resolve, reject) => {
+            Taro.canvasToTempFilePath({
+              canvas,
+              fileType: "jpg",
+              success(res2) {
+                setImgPath(res2.tempFilePath);
+
+                loaded.current = true;
+                resolve(true);
+              },
+              fail(err) {
+                Taro.showToast({
+                  title: err.errMsg,
+                  icon: "none",
+                  duration: 4000,
+                });
+                reject(err);
+              },
+            });
+          });
+        })
+        .exec();
+    } finally {
+      Taro.hideLoading();
+    }
+  };
+
+  const loadImg = (canvas, src) => {
+    return new Promise((res, rej) => {
+      const source = canvas.createImage();
+      source.src = src;
+      source.onload = () => {
+        res(source);
+      };
+      source.onerror = rej;
+    }) as Promise<CanvasImageSource>;
+  };
+
+  const ctxText = (ctx, text, font, align, color, x, y) => {
+    ctx.beginPath();
+    ctx.font = font;
+    ctx.textAlign = align;
+    ctx.fillStyle = color;
+    ctx.fillText(text, x, y);
+    ctx.save();
+  };
+
+  const getTempImgPath = async (src: string) => {
+    return new Promise((resolve, reject) => {
+      Taro.getImageInfo({
+        src,
+        success(res) {
+          resolve(res);
+        },
+        fail(err) {
+          reject(err);
+        },
+      });
+    }) as Promise<Taro.getImageInfo.SuccessCallbackResult>;
+  };
+
+  return (
+    <AtFloatLayout className="cert" {...props}>
+      <View className="cert-wrap">
+        {imgPath ? (
+          <>
+            <Image
+              className="cert__img"
+              src={imgPath}
+              showMenuByLongpress
+              onClick={() => {
+                Taro.previewImage({
+                  current: imgPath,
+                  urls: [imgPath],
+                });
+              }}
+            />
+
+            <View className="cert__tips">长按图片,保存证书</View>
+          </>
+        ) : (
+          <Canvas
+            id="certCanvas"
+            type="2d"
+            className="cert__img"
+            style="position:absolute; top: -200%; left: -200%"
+          />
+        )}
+      </View>
+
+      <Image className="cert__close" src={CloseIcon} onClick={props.onClose} />
+    </AtFloatLayout>
+  );
+};

BIN
src/subModule/images/cert@2x-min.jpg


BIN
src/subModule/images/icon_complete@2x-min.png


+ 3 - 3
src/subModule/pages/feedback/index.scss

@@ -34,8 +34,8 @@
       padding: 0 24px;
 
       &:first-child {
-        margin-bottom: 58px;
-        padding-bottom: 67px;
+        margin-bottom: 38px;
+        padding-bottom: 38px;
         border-bottom: 2px solid #d5ddd7;
       }
     }
@@ -46,7 +46,7 @@
     color: #424a4a;
   }
   &__input {
-    margin: 20px 0 40px;
+    margin: 20px 0 38px;
     padding: 2px;
     width: 100%;
     height: 100px;

+ 20 - 1
src/subModule/pages/feedback/index.tsx

@@ -7,6 +7,7 @@ import { handleValidate } from "../../../utils";
 import { getBaseURL } from "@dage/service";
 import { Rules } from "async-validator";
 import { feedbackApi } from "../../../api";
+import { FooterProtocol } from "../../../components/FooterProtocol";
 import "./index.scss";
 
 const DEFAULT_PARAMS = {
@@ -32,13 +33,29 @@ const formRules: Rules = {
 };
 
 const FeedBackPage: FC = () => {
+  const [checked, setChecked] = useState(false);
   const [params, setParams] = useState({ ...DEFAULT_PARAMS });
   const [timestamp, setTimestamp] = useState(new Date().getTime());
   const [loading, setLoading] = useState(false);
 
-  const handleSubmit = async () => {
+  const handleSubmit = async (event, readProtocol = false) => {
     if (!(await handleValidate(params, formRules))) return;
 
+    if (!checked && !readProtocol) {
+      Taro.showModal({
+        title: "提示",
+        content: "是否已阅读并同意《用户服务协议》及《个人信息保护政策》",
+        confirmText: "同意",
+        success: (res) => {
+          if (res.confirm) {
+            setChecked(true);
+            handleSubmit(undefined, true);
+          }
+        },
+      });
+      return;
+    }
+
     try {
       setLoading(true);
       await feedbackApi(params);
@@ -147,6 +164,8 @@ const FeedBackPage: FC = () => {
               </AtButton>
             </View>
           </View>
+
+          <FooterProtocol checked={checked} setChecked={setChecked} />
         </AtForm>
 
         <Image src={BgImg} className="feedback__bg" mode="widthFix" />

+ 117 - 55
src/subModule/pages/order/index.tsx

@@ -1,19 +1,17 @@
 import { useState } from "react";
 import { Image, Label, View } from "@tarojs/components";
-import Taro, { FC, useRouter } from "@tarojs/taro";
+import Taro, { FC, pxTransform, useRouter } from "@tarojs/taro";
 import { AtButton, AtInput, AtTextarea } from "taro-ui";
-import BgImg from "../../images/feedback@2x-min.png";
 import { Rules } from "async-validator";
+import BgImg from "../../images/feedback@2x-min.png";
 import { handleValidate } from "../../../utils";
 import { redeemApi } from "../../../api";
-import "../feedback/index.scss";
+import CertImg from "../../images/cert@2x-min.jpg";
+import { CertLayout } from "../../components/CertLayout";
+import { FooterProtocol } from "../../../components/FooterProtocol";
+import { formatDate } from "@dage/utils";
 import "./index.scss";
-
-const formRules: Rules = {
-  name: [{ required: true, message: "请输入称呼" }],
-  phone: [{ required: true, message: "请输入联系方式" }],
-  description: [{ required: true, message: "请输入地址及留言" }],
-};
+import "../feedback/index.scss";
 
 const DEFAULT_PARAMS = {
   name: "",
@@ -23,16 +21,47 @@ const DEFAULT_PARAMS = {
 
 const OrderPage: FC = () => {
   const router = useRouter();
+  /**
+   * 是否证书
+   */
+  const isCert = Number(router.params.id) === 1;
   const remainder = Number(router.params.point) - Number(router.params.score);
   const [params, setParams] = useState({ ...DEFAULT_PARAMS });
   const [loading, setLoading] = useState(false);
-
-  const handleSubmit = async () => {
+  const [showCert, setShowCert] = useState(false);
+  const [checked, setChecked] = useState(false);
+
+  const formRules: Rules = isCert
+    ? {
+        name: [{ required: true, message: "请输入称呼" }],
+      }
+    : {
+        name: [{ required: true, message: "请输入称呼" }],
+        phone: [{ required: true, message: "请输入联系方式" }],
+        description: [{ required: true, message: "请输入地址及留言" }],
+      };
+
+  const handleSubmit = async (event, readProtocol = false) => {
     if (!(await handleValidate(params, formRules))) return;
 
+    if (!checked && !readProtocol) {
+      Taro.showModal({
+        title: "提示",
+        content: "是否已阅读并同意《用户服务协议》及《个人信息保护政策》",
+        confirmText: "同意",
+        success: (res) => {
+          if (res.confirm) {
+            setChecked(true);
+            handleSubmit(undefined, true);
+          }
+        },
+      });
+      return;
+    }
+
     Taro.showModal({
       title: "提示",
-      content: "确认是否兑换",
+      content: "确认是否捐赠",
       async success() {
         try {
           setLoading(true);
@@ -42,15 +71,19 @@ const OrderPage: FC = () => {
             score: -Number(router.params.score),
           });
 
-          Taro.showToast({
-            title: "提交成功,工作人员会与您及时联系",
-            icon: "none",
-            duration: 3000,
-          });
-
-          setTimeout(() => {
-            Taro.navigateBack();
-          }, 3000);
+          if (isCert) {
+            setShowCert(true);
+          } else {
+            Taro.showToast({
+              title: "提交成功,工作人员会与您及时联系",
+              icon: "none",
+              duration: 3000,
+            });
+
+            setTimeout(() => {
+              Taro.navigateBack();
+            }, 3000);
+          }
         } finally {
           setLoading(false);
         }
@@ -67,9 +100,9 @@ const OrderPage: FC = () => {
             <AtInput
               name="name"
               className="feedback__input"
-              placeholder="请输入内容,20字以内"
+              placeholder="请输入内容,5字以内"
               value={params.name}
-              maxLength={20}
+              maxLength={5}
               onChange={(val) =>
                 setParams({
                   ...params,
@@ -78,47 +111,65 @@ const OrderPage: FC = () => {
               }
             />
 
-            <Label className="feedback__label">联系方式</Label>
-            <AtInput
-              name="phone"
-              type="phone"
-              maxLength={20}
-              className="feedback__input"
-              placeholder="请输入内容,20字以内"
-              value={params.phone}
-              onChange={(val) =>
-                setParams({
-                  ...params,
-                  phone: val as string,
-                })
-              }
-            />
-
-            <Label className="feedback__label">地址及留言</Label>
-            <AtTextarea
-              className="feedback__input textarea"
-              maxLength={50}
-              placeholder="请输入省市区街道,50字以内"
-              value={params.description}
-              onChange={(val) =>
-                setParams({
-                  ...params,
-                  description: val.trim(),
-                })
-              }
-            />
+            {isCert ? (
+              <>
+                <Label className="feedback__label">效果示意</Label>
+                <Image
+                  src={CertImg}
+                  mode="widthFix"
+                  style={{
+                    margin: `${pxTransform(38)} -${pxTransform(
+                      20
+                    )} ${pxTransform(60)}`,
+                    width: `calc(100% + ${pxTransform(40)})`,
+                  }}
+                />
+              </>
+            ) : (
+              <>
+                <Label className="feedback__label">联系方式</Label>
+                <AtInput
+                  name="phone"
+                  type="phone"
+                  maxLength={20}
+                  className="feedback__input"
+                  placeholder="请输入内容,20字以内"
+                  value={params.phone}
+                  onChange={(val) =>
+                    setParams({
+                      ...params,
+                      phone: val as string,
+                    })
+                  }
+                />
+
+                <Label className="feedback__label">地址及留言</Label>
+                <AtTextarea
+                  className="feedback__input textarea"
+                  maxLength={50}
+                  placeholder="请输入省市区街道,50字以内"
+                  value={params.description}
+                  onChange={(val) =>
+                    setParams({
+                      ...params,
+                      description: val.trim(),
+                    })
+                  }
+                />
+              </>
+            )}
           </View>
 
           <View className="feedback-main__form">
             <View className="order__line">
-              <View className="order__line__label size38 bold">您的积分</View>
+              <View className="order__line__label size38 bold">您的爱心</View>
               <View className="order__line__num size38 bold">
                 {router.params.point}
               </View>
             </View>
 
             <View className="order__line">
-              <View className="order__line__label">消费</View>
+              <View className="order__line__label">捐赠</View>
               <View className="order__line__num red">
                 {router.params.score}
               </View>
@@ -141,14 +192,25 @@ const OrderPage: FC = () => {
                 loading={loading}
                 onClick={handleSubmit}
               >
-                兑换
+                捐赠
               </AtButton>
             </View>
           </View>
+
+          <FooterProtocol checked={checked} setChecked={setChecked} />
         </View>
 
         <Image src={BgImg} className="feedback__bg" mode="widthFix" />
       </View>
+
+      <CertLayout
+        name={params.name}
+        date={formatDate(new Date(), "YYYY年MM月DD日")}
+        isOpened={showCert}
+        onClose={() => {
+          Taro.navigateBack();
+        }}
+      />
     </View>
   );
 };

+ 3 - 0
src/subModule/pages/protocol/index.config.ts

@@ -0,0 +1,3 @@
+export default definePageConfig({
+  navigationBarTitleText: "协议",
+});

+ 31 - 0
src/subModule/pages/protocol/index.tsx

@@ -0,0 +1,31 @@
+import { RichText, View } from "@tarojs/components";
+import Taro, { FC, useRouter } from "@tarojs/taro";
+import { useEffect, useState } from "react";
+import { getProtocolDetailApi } from "../../../api";
+
+const ProtocolPage: FC = () => {
+  const route = useRouter();
+  const [rtf, setRTF] = useState("");
+
+  useEffect(() => {
+    getDetail();
+  }, []);
+
+  const getDetail = async () => {
+    const data = await getProtocolDetailApi(route.params.id);
+
+    setRTF(data.rtf);
+
+    Taro.setNavigationBarTitle({
+      title: data.name,
+    });
+  };
+
+  return (
+    <View style={{ padding: `0 ${Taro.pxTransform(30)}` }}>
+      <RichText nodes={rtf} />
+    </View>
+  );
+};
+
+export default ProtocolPage;

+ 7 - 0
src/subModule/pages/shopmall/components/Products/index.scss

@@ -45,6 +45,13 @@
         font-family: "Source Han Sans CN-Bold";
       }
     }
+    &__is-buy {
+      position: absolute;
+      right: 13px;
+      bottom: 16px;
+      width: 100px;
+      height: 100px;
+    }
     &__buy {
       position: absolute;
       right: 13px;

+ 78 - 51
src/subModule/pages/shopmall/components/Products/index.tsx

@@ -1,8 +1,9 @@
 import { Image, ScrollView, Text, View } from "@tarojs/components";
 import Taro, { FC, useDidShow } from "@tarojs/taro";
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
 import BuyIcon from "../../../../images/icon_presents@2x-min.png";
-import { getProductListApi } from "../../../../../api";
+import IsBuyIcon from "../../../../images/icon_complete@2x-min.png";
+import { checkRedeemApi, getProductListApi } from "../../../../../api";
 import { getBaseURL } from "@dage/service";
 import "./index.scss";
 
@@ -14,16 +15,28 @@ export interface ProductsProps {
    * 用户当前积分
    */
   point: number;
+  openCertLayout: Function;
 }
 
-export const Products: FC<ProductsProps> = ({ point }) => {
+export const Products: FC<ProductsProps> = ({ point, openCertLayout }) => {
   const params = useRef({
     pageNum: 1,
     pageSize: 20,
   });
   const hasMore = useRef(true);
+  /** 是否已购买证书 */
+  const [certIsBuy, setCertIsBuy] = useState(false);
   const [list, setList] = useState<any[]>([]);
 
+  useEffect(() => {
+    checkRedeem();
+  }, []);
+
+  const checkRedeem = async () => {
+    const data = await checkRedeemApi();
+    setCertIsBuy(data);
+  };
+
   const getProductList = async () => {
     if (!hasMore.current) return;
 
@@ -49,59 +62,73 @@ export const Products: FC<ProductsProps> = ({ point }) => {
   });
 
   const handleBuy = (item: any) => {
-    Taro.showToast({
-      title: "暂未开发,敬请期待",
-      icon: "none",
-      duration: 3000,
+    if (point < item.score) {
+      Taro.showToast({
+        title: "积分不足,无法兑换",
+        icon: "none",
+        duration: 3000,
+      });
+      return;
+    }
+
+    if (item.id === 1 && certIsBuy) {
+      openCertLayout();
+      return;
+    }
+
+    Taro.navigateTo({
+      url: `/subModule/pages/order/index?id=${item.id}&score=${item.score}&point=${point}`,
     });
-    // if (point < item.score) {
-    //   Taro.showToast({
-    //     title: "积分不足,无法兑换",
-    //     icon: "none",
-    //     duration: 3000,
-    //   });
-    //   return;
-    // }
-
-    // Taro.navigateTo({
-    //   url: `/subModule/pages/order/index?id=${item.id}&score=${item.score}&point=${point}`,
-    // });
   };
 
   return (
-    <ScrollView
-      scrollY
-      className="products-wrap"
-      onScrollToLower={getProductList}
-    >
-      <View className="products">
-        {list.map((item) => (
-          <View key={item.id} className="products-item">
-            <Image
-              className="products-item__img"
-              src={`${baseUrl}${item.thumb}`}
-              mode="aspectFill"
-            />
-
-            <View className="products-item__main">
-              <View className="products-item__title limit-line">
-                {item.name}
-              </View>
-              <View className="products-item__stock">库存:{item.stock}</View>
-              <View className="products-item__ft">
-                <Text className="products-item__ft__price">{item.score}</Text>
-                <Text>爱心币</Text>
-              </View>
+    <>
+      <ScrollView
+        scrollY
+        className="products-wrap"
+        onScrollToLower={getProductList}
+      >
+        <View className="products">
+          {list.map((item) => {
+            const imgUrl = `${baseUrl}${item.thumb}`;
 
-              <Image
-                className="products-item__buy"
-                src={BuyIcon}
+            return (
+              <View
+                key={item.id}
+                className="products-item"
                 onClick={handleBuy.bind(undefined, item)}
-              />
-            </View>
-          </View>
-        ))}
-      </View>
-    </ScrollView>
+              >
+                <Image
+                  className="products-item__img"
+                  src={imgUrl}
+                  mode="aspectFill"
+                />
+
+                <View className="products-item__main">
+                  <View className="products-item__title limit-line">
+                    {item.name}
+                  </View>
+                  <View className="products-item__stock">
+                    库存:{item.stock}
+                  </View>
+                  <View className="products-item__ft">
+                    <Text className="products-item__ft__price">
+                      {item.score}
+                    </Text>
+                    <Text>爱心币</Text>
+                  </View>
+
+                  {item.id === 1 && certIsBuy ? (
+                    <Image className="products-item__is-buy" src={IsBuyIcon} />
+                  ) : (
+                    <Image className="products-item__buy" src={BuyIcon} />
+                  )}
+                </View>
+              </View>
+            );
+          })}
+        </View>
+      </ScrollView>
+    </>
   );
 };

+ 37 - 5
src/subModule/pages/shopmall/index.tsx

@@ -7,9 +7,12 @@ import { Records } from "./components/Records";
 import {
   getPointRecordListApi,
   getRankingListApi,
+  getRedeemInfoApi,
   getUserPointApi,
 } from "../../../api";
 import { Ranking } from "./components/Ranking";
+import { CertLayout } from "../../../subModule/components/CertLayout";
+import { formatDate } from "@dage/utils";
 import "./index.scss";
 
 enum TABS {
@@ -23,6 +26,11 @@ const ShopmallPage: FC = () => {
   const [point, setPoint] = useState<string | number>("--");
   const [recordList, setRecordList] = useState<any[]>([]);
   const [rankingList, setRankingList] = useState<any[]>([]);
+  const [showCert, setShowCert] = useState(false);
+  const [certInfo, setCertInfo] = useState({
+    name: "",
+    date: "",
+  });
 
   const handleTabClick = (idx: number) => {
     setCurTab(idx);
@@ -51,6 +59,19 @@ const ShopmallPage: FC = () => {
     setRankingList(data);
   };
 
+  const getRedeemInfo = async () => {
+    const data = await getRedeemInfoApi();
+    setCertInfo({
+      name: data.certName,
+      date: formatDate(data.createTime, "YYYY年MM月DD日"),
+    });
+  };
+
+  const openCertLayout = async () => {
+    await getRedeemInfo();
+    setShowCert(true);
+  };
+
   useDidShow(() => {
     getUserPoint();
   });
@@ -68,15 +89,19 @@ const ShopmallPage: FC = () => {
           scroll
           className="shopmall-tab"
           tabList={[
-            { title: "爱心捐赠" },
+            { title: "爱心兑换" },
             { title: "排行榜" },
-            { title: "捐赠记录" },
+            { title: "爱心记录" },
           ]}
           onClick={handleTabClick}
         >
-          {/* 爱心捐赠 */}
+          {/* 爱心兑换 */}
           <AtTabsPane current={curTab} index={TABS.SHOP}>
-            <Products curTab={curTab} point={point as number} />
+            <Products
+              curTab={curTab}
+              point={point as number}
+              openCertLayout={openCertLayout}
+            />
           </AtTabsPane>
 
           {/* 排行榜 */}
@@ -84,12 +109,19 @@ const ShopmallPage: FC = () => {
             <Ranking list={rankingList} />
           </AtTabsPane>
 
-          {/* 捐赠记录 */}
+          {/* 爱心记录 */}
           <AtTabsPane current={curTab} index={TABS.RECORD}>
             <Records list={recordList} active={curTab === TABS.RECORD} />
           </AtTabsPane>
         </AtTabs>
       </View>
+
+      <CertLayout
+        name={certInfo.name}
+        date={certInfo.date}
+        isOpened={showCert}
+        onClose={() => setShowCert(false)}
+      />
     </View>
   );
 };