shuisheng 1 anno fa
parent
commit
62757da92d

+ 0 - 1
src/App.vue

@@ -1,7 +1,6 @@
 <template>
   <n-config-provider :theme-overrides="themeOverrides">
     <n-dialog-provider>
-      <RenderMarkdown :md="modalContent"></RenderMarkdown>
       <router-view></router-view>
       <HomeModal
         :show="showModal"

+ 4 - 0
src/api/goods.ts

@@ -1,6 +1,10 @@
 import { IGoods, IList, IPaging } from '@/interface';
 import request from '@/utils/request';
 
+export function fetchGoodsFind(id: number) {
+  return request.get<IGoods>(`/goods/find/${id}`);
+}
+
 export function fetchGoodsList(params: IList<IGoods>) {
   return request.get<IPaging<IGoods>>('/goods/list', { params });
 }

+ 1 - 1
src/assets/constant.scss

@@ -65,7 +65,7 @@ $header-height: 60px;
 %customScrollbarHide {
   // 整个滚动条
   &::-webkit-scrollbar {
-    width: 8px;
+    width: 0px;
     height: 0px;
     border-radius: 0px;
     background: rgba(0, 0, 0, 0);

+ 2 - 1
src/components/FloatTip/index.vue

@@ -11,7 +11,7 @@
       {{ handleStrEllipsis(txt, maxLen) }}
     </div>
     <div
-      v-if="show"
+      v-if="show && txt.length > maxLen"
       ref="floatTxtRef"
       class="float-txt"
     >
@@ -72,6 +72,7 @@ function handleMouseleave() {
 <style lang="scss" scoped>
 .float-tip-wrap {
   display: inline-block;
+  line-height: 1;
   .float-txt {
     position: fixed;
     top: 0;

+ 34 - 13
src/components/FullLoading/main.vue

@@ -2,30 +2,51 @@
   <div
     v-show="loading"
     :class="{ 'full-loading-wrap': 1, [isFixed ? 'fixed' : 'absolute']: 1 }"
+    :style="{ zIndex: zindex || normalZindex }"
   >
-    <div :style="style">
+    <div>
       <div
-        v-if="showMask"
+        v-if="mask"
         class="mask"
       ></div>
-      <div class="container"></div>
-      <div class="txt">{{ content }}</div>
+      <div
+        class="container"
+        :style="{ '--loading-size': loadingSize + 'px' }"
+      ></div>
+      <div
+        v-if="content && content !== ''"
+        class="txt"
+        :style="{ color: contentColor }"
+      >
+        {{ content }}
+      </div>
     </div>
   </div>
 </template>
 
 <script lang="ts">
-import { StyleValue, defineComponent, ref } from 'vue';
+import { defineComponent, ref } from 'vue';
 
 export default defineComponent({
-  name: 'fullLoading',
   setup() {
     const isFixed = ref(false);
     const loading = ref(false);
-    const showMask = ref(false);
+    const mask = ref(false);
+    const normalZindex = ref(10);
+    const loadingSize = ref(30);
+    const zindex = ref(normalZindex);
     const content = ref('');
-    const style = ref<StyleValue>();
-    return { content, style, loading, showMask, isFixed };
+    const contentColor = ref('#999');
+    return {
+      content,
+      contentColor,
+      loading,
+      isFixed,
+      mask,
+      zindex,
+      normalZindex,
+      loadingSize,
+    };
   },
 });
 </script>
@@ -34,8 +55,6 @@ export default defineComponent({
 @import 'billd-scss/src/animate/loading-size.scss';
 
 .full-loading-wrap {
-  z-index: 10;
-
   @extend %flexCenter;
   &.fixed {
     @include full(fixed);
@@ -44,13 +63,15 @@ export default defineComponent({
     @include full(absolute);
   }
   .mask {
+    position: absolute !important;
+    background-color: rgba($color: #000000, $alpha: 0.2) !important;
+
     @extend %maskBg;
   }
   .container {
-    @include loadingSizeChange(30px, rgba($theme-color-gold, 0.5));
+    @include loadingSizeChange(var(--loading-size), $theme-color-gold);
   }
   .txt {
-    position: relative;
     margin-top: 10px;
     font-size: 14px;
   }

+ 15 - 14
src/components/LongList/index.vue

@@ -3,17 +3,6 @@
     ref="longListRef"
     class="long-list-wrap"
   >
-    <!-- <div
-      style="
-        position: fixed;
-        bottom: 10px;
-        left: 10px;
-        z-index: 999;
-        color: red;
-      "
-    >
-      {{ status }}
-    </div> -->
     <slot></slot>
     <div
       v-if="status === 'loading'"
@@ -33,7 +22,10 @@
     >
       {{ t('common.allLoaded') }}
     </div>
-    <div ref="bottomRef"></div>
+    <div
+      class="bottom-ref"
+      ref="bottomRef"
+    ></div>
   </div>
 </template>
 
@@ -82,10 +74,10 @@ function monitTouchBottom() {
     (entries) => {
       entries.forEach((item) => {
         if (item.isIntersecting) {
-          // console.log('到底了');
+          console.log('到底了');
           emits('getListData');
         } else {
-          // console.log('隐藏了');
+          console.log('隐藏了');
         }
       });
     },
@@ -118,5 +110,14 @@ onUnmounted(() => {
     width: 100%;
     text-align: center;
   }
+  .bottom-ref {
+    // width: 10px;
+    // height: 10px;
+    // background-color: red;
+    // position: absolute;
+    // bottom: 0;
+    // left: 0;
+    // display: none;
+  }
 }
 </style>

+ 0 - 1
src/components/Modal/index.vue

@@ -18,7 +18,6 @@
       </div>
       <div class="footer">
         <slot name="footer"></slot>
-
         <div
           v-if="!slots.footer"
           class="btn"

+ 7 - 3
src/components/QrPay/index.vue

@@ -1,7 +1,10 @@
 <template>
-  <div class="qr-pay-wrap">
+  <div
+    class="qr-pay-wrap"
+    :class="{ mobile }"
+  >
     <div class="money">
-      {{ t('common.payMoney', { money: formatMoney(props.money) }) }}
+      {{ t('common.payMoney', { money: formatMoney(props.money!) }) }}
     </div>
     <div
       class="qrcode-wrap"
@@ -92,6 +95,7 @@ const downTimeStart = ref();
 const downTimeEnd = ref();
 const loading = ref(false);
 const isExpired = ref(false);
+const mobile = ref(false);
 
 const currentPayStatus = ref(PayStatusEnum.wait);
 const { t } = useI18n();
@@ -115,6 +119,7 @@ onUnmounted(() => {
 
 onMounted(() => {
   handleStartPay();
+  mobile.value = isMobile();
 });
 
 async function generateQR(text) {
@@ -200,7 +205,6 @@ function getPayStatus(outTradeNo: string) {
 
 <style lang="scss" scoped>
 .qr-pay-wrap {
-  padding: 10px;
   .money {
     text-align: center;
     font-size: 20px;

+ 7 - 3
src/components/RenderMarkdown/index.vue

@@ -1,5 +1,8 @@
 <template>
-  <div class="mardown-wrap">
+  <div
+    class="mardown-wrap"
+    :style="{ height: height ? height + 'px' : 'auto' }"
+  >
     <MdPreview :modelValue="text" />
   </div>
 </template>
@@ -13,9 +16,11 @@ import 'md-editor-v3/lib/preview.css';
 const props = withDefaults(
   defineProps<{
     md: string;
+    height: number;
   }>(),
   {
     md: '',
+    height: 0,
   }
 );
 const text = ref('');
@@ -35,9 +40,8 @@ watch(
 .mardown-wrap {
   overflow: scroll;
   width: 100%;
-  height: 400px;
 
-  @extend %customScrollbar;
+  @extend %customScrollbarHide;
 
   :deep(.md-editor-preview-wrapper) {
     padding: 0;

+ 9 - 1
src/constant.ts

@@ -1,4 +1,4 @@
-import { MediaTypeEnum } from '@/interface';
+import { GoodsTypeEnum, MediaTypeEnum } from '@/interface';
 import { prodDomain, QINIU_KODO } from '@/spec-config';
 import { LiveRoomTypeEnum } from '@/types/ILiveRoom';
 
@@ -184,6 +184,14 @@ export const lsKey = {
   token: 'token',
 };
 
+export const goodsTypeEnumMap = {
+  [GoodsTypeEnum.recharge]: '充值',
+  [GoodsTypeEnum.gift]: '礼物',
+  [GoodsTypeEnum.sponsors]: '赞助',
+  [GoodsTypeEnum.support]: '服务',
+  [GoodsTypeEnum.qypShop]: '逸鹏的商品',
+};
+
 export const mediaTypeEnumMap = {
   [MediaTypeEnum.camera]: '摄像头',
   [MediaTypeEnum.microphone]: '麦克风',

+ 56 - 10
src/directives/loading/index.ts

@@ -1,47 +1,93 @@
+import { judgeType } from 'billd-utils';
 import { App, ComponentPublicInstance, Directive, createApp } from 'vue';
 
-import fullLoading from '@/components/FullLoading/main.vue';
+import main from '@/components/FullLoading/main.vue';
 
 const map = new Map<
   HTMLElement,
   {
     app: App<Element>;
-    instance: ComponentPublicInstance<InstanceType<typeof fullLoading>>;
+    instance: ComponentPublicInstance<InstanceType<typeof main>>;
   }
 >();
 
+const vLoadingProp = {
+  'loading-content': 'loading-content',
+  'loading-content-color': 'loading-content-color',
+  'loading-mask-zindex': 'loading-mask-zindex',
+  'loading-size': 'loading-size',
+};
+
 export const directiveLoading: Directive = {
   // 在绑定元素的 attribute 前
   // 或事件监听器应用前调用
-  // created() {},
+  created() {},
   // 在元素被插入到 DOM 前调用
-  // beforeMount() {},
+  beforeMount() {},
   // 在绑定元素的父组件
   // 及他自己的所有子节点都挂载完成后调用
-  mounted(el, binding) {
-    const { value } = binding;
-    const app = createApp(fullLoading);
+  mounted(el: HTMLElement, binding) {
+    const { value, modifiers } = binding;
+    const content = el.getAttribute(vLoadingProp['loading-content']);
+    const contentColor = el.getAttribute(vLoadingProp['loading-content-color']);
+    const zIndex = el.getAttribute(vLoadingProp['loading-mask-zindex']);
+    const size = el.getAttribute(vLoadingProp['loading-size']);
+    const app = createApp(main);
     const container = document.createElement('div');
+    container.style.position = 'absolute';
+    container.style.left = '0';
+    container.style.right = '0';
+    container.style.top = '0';
+    container.style.bottom = '0';
+    container.style.pointerEvents = 'none';
     // @ts-ignore
-    const instance: ComponentPublicInstance<InstanceType<typeof fullLoading>> =
+    const instance: ComponentPublicInstance<InstanceType<typeof main>> =
       app.mount(container);
     el.appendChild(container);
+
     instance.loading = value;
+    instance.mask = modifiers.mask;
+    if (zIndex !== null) {
+      instance.zindex = Number(zIndex);
+    }
+    if (content !== null) {
+      instance.content = content;
+    }
+    if (contentColor !== null) {
+      instance.contentColor = contentColor;
+    }
+    if (size !== null) {
+      instance.loadingSize = Number(size);
+    }
     map.set(el, { app, instance });
     return instance;
   },
   // 绑定元素的父组件更新前调用
-  // beforeUpdate() {},
+  beforeUpdate() {},
   // 在绑定元素的父组件及他自己的所有子节点都更新后调用
   updated(el, binding) {
     const { value } = binding;
     const res = map.get(el);
     if (res) {
       res.instance.loading = value;
+      const content = el.getAttribute(vLoadingProp['loading-content']);
+      const contentColor = el.getAttribute(
+        vLoadingProp['loading-content-color']
+      );
+      const size = el.getAttribute(vLoadingProp['loading-size']);
+      if (content !== null && judgeType(content) === 'string') {
+        res.instance.content = content;
+      }
+      if (contentColor !== null && judgeType(contentColor) === 'string') {
+        res.instance.contentColor = contentColor;
+      }
+      if (size !== null && judgeType(size) === 'string') {
+        res.instance.loadingSize = size;
+      }
     }
   },
   // 绑定元素的父组件卸载前调用
-  // beforeUnmount() {},
+  beforeUnmount() {},
   // 绑定元素的父组件卸载后调用
   unmounted(el) {
     map.get(el)?.app.unmount();

+ 17 - 103
src/hooks/use-pull.ts

@@ -3,7 +3,6 @@ import { nextTick, onUnmounted, ref, watch } from 'vue';
 import { useRoute } from 'vue-router';
 
 import { URL_QUERY } from '@/constant';
-import { commentAuthTip, loginTip } from '@/hooks/use-login';
 import { useFlvPlay, useHlsPlay } from '@/hooks/use-play';
 import { useWebsocket } from '@/hooks/use-websocket';
 import { useWebRtcRtmpToRtc } from '@/hooks/webrtc/rtmpToRtc';
@@ -11,8 +10,7 @@ import {
   DanmuMsgTypeEnum,
   LiveLineEnum,
   LiveRenderEnum,
-  WsMessageContentTypeEnum,
-  WsMessageMsgIsFileEnum,
+  WsMessageIsFileEnum,
 } from '@/interface';
 import { useAppStore } from '@/store/app';
 import { useCacheStore } from '@/store/cache';
@@ -20,7 +18,6 @@ import { useNetworkStore } from '@/store/network';
 import { ILiveRoom, LiveRoomTypeEnum } from '@/types/ILiveRoom';
 import {
   WsBatchSendOffer,
-  WsMessageType,
   WsMsgTypeEnum,
   WsOfferType,
 } from '@/types/websocket';
@@ -42,7 +39,7 @@ export function usePull() {
   const appStore = useAppStore();
   const danmuStr = ref('');
   const roomId = ref('');
-  const msgIsFile = ref(WsMessageMsgIsFileEnum.no);
+  const msgIsFile = ref(WsMessageIsFileEnum.no);
   const danmuMsgType = ref<DanmuMsgTypeEnum>(DanmuMsgTypeEnum.danmu);
   const liveRoomInfo = ref<ILiveRoom>();
   const autoplayVal = ref(false);
@@ -55,8 +52,15 @@ export function usePull() {
   const videoResolution = ref();
   const remoteVideo = ref<Array<HTMLVideoElement | HTMLCanvasElement>>([]);
   const remoteStream = ref<MediaStream[]>([]);
-  const { mySocketId, initWs, roomLiving, anchorInfo, liveUserList, damuList } =
-    useWebsocket();
+  const {
+    mySocketId,
+    initWs,
+    roomLiving,
+    anchorInfo,
+    liveUserList,
+    damuList,
+    sendDanmuTxt,
+  } = useWebsocket();
   const { updateWebRtcRtmpToRtcConfig, webRtcRtmpToRtc } = useWebRtcRtmpToRtc();
   const { updateWebRtcLiveConfig, webRtcLive } = useWebRtcLive();
   const { updateWebRtcMeetingOneConfig, webRtcMeetingOne } =
@@ -519,20 +523,17 @@ export function usePull() {
     }
   );
 
-  function initRoomId(id: string) {
-    roomId.value = id;
-  }
-
-  function initPull(data: { autolay?: boolean }) {
+  function initPull(data: { autolay?: boolean; roomId: string }) {
+    roomId.value = data.roomId;
     if (data.autolay === undefined) {
       autoplayVal.value = true;
     } else {
       autoplayVal.value = data.autolay;
     }
-    initWs({
-      roomId: roomId.value,
-      isAnchor: false,
-    });
+    // initWs({
+    //   roomId: roomId.value,
+    //   isAnchor: false,
+    // });
   }
 
   function closeWs() {
@@ -555,89 +556,6 @@ export function usePull() {
     }
   }
 
-  function sendDanmuReward(txt: string) {
-    if (!loginTip()) {
-      return;
-    }
-    if (!commentAuthTip()) {
-      return;
-    }
-    if (!txt.trim().length) {
-      window.$message.warning('请输入弹幕内容!');
-      return;
-    }
-    const instance = networkStore.wsMap.get(roomId.value);
-    if (!instance) return;
-    const messageData: WsMessageType['data'] = {
-      content: txt,
-      content_type: WsMessageContentTypeEnum.txt,
-      msg_type: DanmuMsgTypeEnum.reward,
-      live_room_id: Number(roomId.value),
-      isBilibili: false,
-    };
-    instance.send({
-      requestId: getRandomString(8),
-      msgType: WsMsgTypeEnum.message,
-      data: messageData,
-    });
-  }
-
-  function sendDanmuTxt(txt: string) {
-    if (!loginTip()) {
-      return;
-    }
-    if (!commentAuthTip()) {
-      return;
-    }
-    if (!txt.trim().length) {
-      window.$message.warning('请输入弹幕内容!');
-      return;
-    }
-    const instance = networkStore.wsMap.get(roomId.value);
-    if (!instance) return;
-    const messageData: WsMessageType['data'] = {
-      content: txt,
-      content_type: WsMessageContentTypeEnum.txt,
-      msg_type: DanmuMsgTypeEnum.danmu,
-      live_room_id: Number(roomId.value),
-      isBilibili: false,
-    };
-    instance.send({
-      requestId: getRandomString(8),
-      msgType: WsMsgTypeEnum.message,
-      data: messageData,
-    });
-    danmuStr.value = '';
-  }
-
-  function sendDanmuImg(url: string) {
-    if (!loginTip()) {
-      return;
-    }
-    if (!commentAuthTip()) {
-      return;
-    }
-    if (!url.trim().length) {
-      window.$message.warning('图片不能为空!');
-      return;
-    }
-    const instance = networkStore.wsMap.get(roomId.value);
-    if (!instance) return;
-    const requestId = getRandomString(8);
-    const messageData: WsMessageType['data'] = {
-      content: url,
-      content_type: WsMessageContentTypeEnum.img,
-      msg_type: DanmuMsgTypeEnum.danmu,
-      live_room_id: Number(roomId.value),
-      isBilibili: false,
-    };
-    instance.send({
-      requestId,
-      msgType: WsMsgTypeEnum.message,
-      data: messageData,
-    });
-  }
-
   return {
     initWs,
     initRtcReceive,
@@ -648,9 +566,6 @@ export function usePull() {
     closeWs,
     closeRtc,
     keydownDanmu,
-    sendDanmuReward,
-    sendDanmuTxt,
-    sendDanmuImg,
     showPlayBtn,
     danmuMsgType,
     isPlaying,
@@ -666,6 +581,5 @@ export function usePull() {
     danmuStr,
     liveRoomInfo,
     anchorInfo,
-    initRoomId,
   };
 }

+ 7 - 43
src/hooks/use-push.ts

@@ -2,17 +2,15 @@ import { getRandomString, windowReload } from 'billd-utils';
 import { onMounted, onUnmounted, ref, watch } from 'vue';
 
 import { fetchCreateUserLiveRoom } from '@/api/userLiveRoom';
-import {
-  DanmuMsgTypeEnum,
-  WsMessageContentTypeEnum,
-  WsMessageMsgIsFileEnum,
-} from '@/interface';
+import { loginTip } from '@/hooks/use-login';
+import { useTip } from '@/hooks/use-tip';
+import { useWebsocket } from '@/hooks/use-websocket';
+import { WsMessageIsFileEnum } from '@/interface';
 import { useAppStore } from '@/store/app';
 import { useNetworkStore } from '@/store/network';
 import { useUserStore } from '@/store/user';
 import {
   WsConnectStatusEnum,
-  WsMessageType,
   WsMsgTypeEnum,
   WsMsrBlobType,
   WsRoomNoLiveType,
@@ -20,10 +18,6 @@ import {
 import { createVideo, generateBase64 } from '@/utils';
 import { handlConstraints } from '@/utils/network/webRTC';
 
-import { commentAuthTip, loginTip } from './use-login';
-import { useTip } from './use-tip';
-import { useWebsocket } from './use-websocket';
-
 export function usePush() {
   const appStore = useAppStore();
   const userStore = useUserStore();
@@ -33,12 +27,13 @@ export function usePush() {
   const danmuStr = ref('');
   const localStream = ref<MediaStream>();
   const videoElArr = ref<HTMLVideoElement[]>([]);
-  const msgIsFile = ref<WsMessageMsgIsFileEnum>(WsMessageMsgIsFileEnum.no);
+  const msgIsFile = ref<WsMessageIsFileEnum>(WsMessageIsFileEnum.no);
 
   const {
     roomLiving,
     initWs,
     handleStartLive,
+    sendDanmuTxt,
     connectStatus,
     mySocketId,
     canvasVideoStream,
@@ -284,44 +279,13 @@ export function usePush() {
     const key = event.key.toLowerCase();
     if (key === 'enter') {
       event.preventDefault();
-      sendDanmu();
-    }
-  }
-
-  function sendDanmu() {
-    if (!loginTip()) {
-      return;
+      sendDanmuTxt(danmuStr.value);
     }
-    if (!commentAuthTip()) {
-      return;
-    }
-    if (!danmuStr.value.length) {
-      window.$message.warning('请输入弹幕内容!');
-      return;
-    }
-    const instance = networkStore.wsMap.get(roomId.value);
-    // if (!instance) {
-    //   window.$message.error('还没开播,不能发送弹幕!');
-    //   return;
-    // }
-    instance?.send<WsMessageType['data']>({
-      requestId: getRandomString(8),
-      msgType: WsMsgTypeEnum.message,
-      data: {
-        content: danmuStr.value,
-        content_type: WsMessageContentTypeEnum.txt,
-        msg_type: DanmuMsgTypeEnum.danmu,
-        live_room_id: Number(roomId.value),
-        isBilibili: false,
-      },
-    });
-    danmuStr.value = '';
   }
 
   return {
     startLive,
     endLive,
-    sendDanmu,
     keydownDanmu,
     sendBlob,
     sendRoomNoLive,

+ 42 - 42
src/hooks/use-upload.ts

@@ -17,6 +17,48 @@ export async function useUpload({
 }: {
   prefix: string;
   file: File;
+}) {
+  const { hash, ext } = await getHash(file);
+  const res = await fetchQiniuUploadToken({ ext, hash, prefix });
+  if (res.code === 200) {
+    return new Promise<{ flag: boolean; err?: any; resultUrl?: string }>(
+      (resolve) => {
+        const key = `${prefix + hash}.${ext}`;
+        const observable = upload(file, key, res.data);
+        observable.subscribe({
+          next(res) {
+            console.log('next', res);
+          },
+          error(err) {
+            console.log('error', err);
+            resolve({
+              flag: false,
+              err,
+            });
+          },
+          complete(res) {
+            console.log('complete', res);
+            resolve({
+              flag: true,
+              resultUrl: `${QINIU_KODO.hssblog.url}/${res.key as string}`,
+            });
+          },
+        });
+      }
+    );
+  } else {
+    return Promise.resolve<{ flag: boolean; err?: any; resultUrl?: string }>({
+      flag: false,
+    });
+  }
+}
+
+export async function useUploadServer({
+  prefix,
+  file,
+}: {
+  prefix: string;
+  file: File;
 }): Promise<
   | {
       flag: boolean;
@@ -117,45 +159,3 @@ export async function useUpload({
     clearInterval(timer.value);
   }
 }
-
-export async function useQiniuJsUpload({
-  prefix,
-  file,
-}: {
-  prefix: string;
-  file: File;
-}) {
-  const { hash, ext } = await getHash(file);
-  const res = await fetchQiniuUploadToken({ ext, hash, prefix });
-  if (res.code === 200) {
-    return new Promise<{ flag: boolean; err?: any; resultUrl?: string }>(
-      (resolve) => {
-        const key = `${prefix + hash}.${ext}`;
-        const observable = upload(file, key, res.data);
-        observable.subscribe({
-          next(res) {
-            console.log('next', res);
-          },
-          error(err) {
-            console.log('error', err);
-            resolve({
-              flag: false,
-              err,
-            });
-          },
-          complete(res) {
-            console.log('complete', res);
-            resolve({
-              flag: true,
-              resultUrl: `${QINIU_KODO.hssblog.url}/${res.key as string}`,
-            });
-          },
-        });
-      }
-    );
-  } else {
-    return Promise.resolve<{ flag: boolean; err?: any; resultUrl?: string }>({
-      flag: false,
-    });
-  }
-}

+ 108 - 20
src/hooks/use-websocket.ts

@@ -5,8 +5,12 @@ import { useRoute } from 'vue-router';
 
 import { fetchVerifyPkKey } from '@/api/liveRoom';
 import { THEME_COLOR, URL_QUERY } from '@/constant';
+import { commentAuthTip, loginTip } from '@/hooks/use-login';
 import { useRTCParams } from '@/hooks/use-rtcParams';
 import { useTip } from '@/hooks/use-tip';
+import { useForwardAll } from '@/hooks/webrtc/forwardAll';
+import { useForwardBilibili } from '@/hooks/webrtc/forwardBilibili';
+import { useForwardHuya } from '@/hooks/webrtc/forwardHuya';
 import { useWebRtcLive } from '@/hooks/webrtc/live';
 import { useWebRtcMeetingOne } from '@/hooks/webrtc/meetingOne';
 import { useWebRtcMeetingPk } from '@/hooks/webrtc/meetingPk';
@@ -17,6 +21,7 @@ import {
   ILiveUser,
   IWsMessage,
   WsMessageContentTypeEnum,
+  WsMessageIsBilibiliEnum,
 } from '@/interface';
 import router, { routerName } from '@/router';
 import { WEBSOCKET_URL } from '@/spec-config';
@@ -55,10 +60,6 @@ import {
   prettierReceiveWsMsg,
 } from '@/utils/network/webSocket';
 
-import { useForwardAll } from './webrtc/forwardAll';
-import { useForwardBilibili } from './webrtc/forwardBilibili';
-import { useForwardHuya } from './webrtc/forwardHuya';
-
 export const useWebsocket = () => {
   const route = useRoute();
   const appStore = useAppStore();
@@ -130,6 +131,95 @@ export const useWebsocket = () => {
     return networkStore.wsMap.get(roomId.value)?.socketIo?.id || '-1';
   });
 
+  function sendDanmuTxt(txt: string) {
+    if (!loginTip()) {
+      return;
+    }
+    if (!commentAuthTip()) {
+      return;
+    }
+    if (!txt.trim().length) {
+      window.$message.warning('请输入弹幕内容!');
+      return;
+    }
+    const instance = networkStore.wsMap.get(roomId.value);
+
+    if (!instance) return;
+    const messageData: WsMessageType['data'] = {
+      content: txt,
+      content_type: WsMessageContentTypeEnum.txt,
+      msg_type: DanmuMsgTypeEnum.danmu,
+      live_room_id: Number(roomId.value),
+      is_bilibili: isBilibili.value
+        ? WsMessageIsBilibiliEnum.yes
+        : WsMessageIsBilibiliEnum.no,
+    };
+    instance.send({
+      requestId: getRandomString(8),
+      msgType: WsMsgTypeEnum.message,
+      data: messageData,
+    });
+  }
+
+  function sendDanmuImg(url: string) {
+    if (!loginTip()) {
+      return;
+    }
+    if (!commentAuthTip()) {
+      return;
+    }
+    if (!url.trim().length) {
+      window.$message.warning('图片不能为空!');
+      return;
+    }
+    const instance = networkStore.wsMap.get(roomId.value);
+    if (!instance) return;
+    const requestId = getRandomString(8);
+    const messageData: WsMessageType['data'] = {
+      content: url,
+      content_type: WsMessageContentTypeEnum.img,
+      msg_type: DanmuMsgTypeEnum.danmu,
+      live_room_id: Number(roomId.value),
+      is_bilibili: isBilibili.value
+        ? WsMessageIsBilibiliEnum.yes
+        : WsMessageIsBilibiliEnum.no,
+    };
+    instance.send({
+      requestId,
+      msgType: WsMsgTypeEnum.message,
+      data: messageData,
+    });
+  }
+
+  function sendDanmuReward(txt: string) {
+    if (!loginTip()) {
+      return;
+    }
+    if (!commentAuthTip()) {
+      return;
+    }
+    if (!txt.trim().length) {
+      window.$message.warning('请输入弹幕内容!');
+      return;
+    }
+    const instance = networkStore.wsMap.get(roomId.value);
+    if (!instance) return;
+    const messageData: WsMessageType['data'] = {
+      content: txt,
+      content_type: WsMessageContentTypeEnum.txt,
+      msg_type: DanmuMsgTypeEnum.reward,
+      live_room_id: Number(roomId.value),
+      is_bilibili: isBilibili.value
+        ? WsMessageIsBilibiliEnum.yes
+        : WsMessageIsBilibiliEnum.no,
+    };
+    instance.send({
+      requestId: getRandomString(8),
+      msgType: WsMsgTypeEnum.message,
+      data: messageData,
+    });
+  }
+
   function handleHeartbeat() {
     loopHeartbeatTimer.value = setInterval(() => {
       const ws = networkStore.wsMap.get(roomId.value);
@@ -504,21 +594,11 @@ export const useWebsocket = () => {
     // 收到用户发送消息
     ws.socketIo.on(WsMsgTypeEnum.message, (data: WsMessageType) => {
       prettierReceiveWsMsg(WsMsgTypeEnum.message, data);
-      damuList.value.push({
-        send_msg_time: data.time,
-        user: data.user_info,
-        username: data.user_info?.username,
-        /** 消息类型 */
-        msg_type: data.data.msg_type,
-        /** 消息内容类型 */
-        content_type: data.data.content_type,
-        /** 消息内容 */
-        content: data.data.content,
-        live_room_id: data.data.live_room_id,
-        redbag_send_id: data.data.redbag_send_id,
-        /** 消息id */
-        id: data.data.msg_id,
-      });
+      // @ts-ignore
+      data.data.send_msg_time = new Date(
+        data.data.send_msg_time!
+      ).toLocaleString();
+      damuList.value.push(data.data);
     });
 
     // 收到disableSpeaking
@@ -832,6 +912,10 @@ export const useWebsocket = () => {
     });
   }
 
+  function initRoomId(id: string) {
+    roomId.value = id;
+  }
+
   function initWs(data: {
     isAnchor: boolean;
     roomId: string;
@@ -840,7 +924,7 @@ export const useWebsocket = () => {
     currentMaxFramerate?: number;
     currentMaxBitrate?: number;
   }) {
-    roomId.value = data.roomId;
+    initRoomId(data.roomId);
     isAnchor.value = data.isAnchor;
 
     if (data.isBilibili !== undefined) {
@@ -864,8 +948,12 @@ export const useWebsocket = () => {
   }
 
   return {
+    initRoomId,
     initWs,
     handleStartLive,
+    sendDanmuTxt,
+    sendDanmuImg,
+    sendDanmuReward,
     isBilibili,
     connectStatus,
     mySocketId,

+ 15 - 5
src/interface.ts

@@ -154,7 +154,7 @@ export interface IQiniuData {
   qiniu_md5?: string;
 }
 
-export enum WsMessageMsgIsFileEnum {
+export enum WsMessageIsFileEnum {
   yes,
   no,
 }
@@ -165,12 +165,17 @@ export enum WsMessageContentTypeEnum {
   video,
 }
 
-export enum WsMessageMsgIsShowEnum {
+export enum WsMessageIsShowEnum {
   yes,
   no,
 }
 
-export enum WsMessageMsgIsVerifyEnum {
+export enum WsMessageIsVerifyEnum {
+  yes,
+  no,
+}
+
+export enum WsMessageIsBilibiliEnum {
   yes,
   no,
 }
@@ -189,8 +194,9 @@ export interface IWsMessage {
   msg_type?: DanmuMsgTypeEnum;
   user_agent?: string;
   send_msg_time?: number;
-  is_show?: WsMessageMsgIsShowEnum;
-  is_verify?: WsMessageMsgIsVerifyEnum;
+  is_show?: WsMessageIsShowEnum;
+  is_verify?: WsMessageIsVerifyEnum;
+  is_bilibili?: WsMessageIsBilibiliEnum;
   remark?: string;
 
   user?: IUser;
@@ -563,6 +569,7 @@ export enum GoodsTypeEnum {
   sponsors = 'sponsors',
   gift = 'gift',
   recharge = 'recharge',
+  qypShop = 'qypShop',
 }
 
 export interface ISettings {
@@ -586,9 +593,12 @@ export interface IGoods {
   price?: number;
   original_price?: number;
   nums?: number;
+  pay_nums?: number;
+  inventory?: number;
   badge?: string;
   badge_bg?: string;
   remark?: string;
+
   created_at?: string;
   updated_at?: string;
   deleted_at?: string;

+ 1 - 1
src/layout/pc/index.vue

@@ -7,7 +7,7 @@
     </router-view>
     <SidebarCpt></SidebarCpt>
     <LoginModal v-if="appStore.showLoginModal"></LoginModal>
-    <PayCourse></PayCourse>
+    <PayCourse v-if="appStore.usePayCourse"></PayCourse>
   </div>
 </template>
 

+ 66 - 2
src/router/index.ts

@@ -17,6 +17,10 @@ export const mobileRouterName = {
   h5Area: 'h5Area',
   h5Rank: 'h5Rank',
   h5My: 'h5My',
+  h5Shop: 'h5Shop',
+  h5ShopDetail: 'h5ShopDetail',
+  h5Store: 'h5Store',
+  h5StoreDetail: 'h5StoreDetail',
   ...commonRouterName,
 };
 
@@ -56,6 +60,7 @@ export const routerName = {
 
   pull: 'pull',
   push: 'push',
+  store: 'store',
   ...mobileRouterName,
 };
 
@@ -229,6 +234,11 @@ export const defaultRoutes: RouteRecordRaw[] = [
       },
     ],
   },
+  {
+    name: routerName.store,
+    path: '/store',
+    component: () => import('@/views/store/index.vue'),
+  },
   {
     path: '/h5',
     component: MobileLayout,
@@ -253,6 +263,16 @@ export const defaultRoutes: RouteRecordRaw[] = [
         path: 'my',
         component: () => import('@/views/h5/my/index.vue'),
       },
+      {
+        name: mobileRouterName.h5Shop,
+        path: 'shop',
+        component: () => import('@/views/h5/shop/index.vue'),
+      },
+      {
+        name: mobileRouterName.h5ShopDetail,
+        path: 'shop/:id',
+        component: () => import('@/views/h5/shopDetail/index.vue'),
+      },
     ],
   },
   {
@@ -260,6 +280,21 @@ export const defaultRoutes: RouteRecordRaw[] = [
     path: '/h5/:roomId',
     component: () => import('@/views/h5/room/index.vue'),
   },
+  {
+    name: mobileRouterName.h5Store,
+    path: '/h5/store',
+    component: () => import('@/views/h5/store/index.vue'),
+  },
+  {
+    name: mobileRouterName.h5StoreDetail,
+    path: '/h5/store/:id',
+    component: () => import('@/views/h5/storeDetail/index.vue'),
+  },
+  // {
+  //   name: mobileRouterName.h5Shop,
+  //   path: '/h5/shop',
+  //   component: () => import('@/views/h5/shop/index.vue'),
+  // },
 ];
 
 const router = createRouter({
@@ -278,10 +313,22 @@ router.beforeEach((to, from, next) => {
   if (to.name === routerName.oauth) {
     return next();
   }
+  if (to.name === routerName.store) {
+    return next({
+      name: routerName.h5Store,
+    });
+  }
+  if (to.name === routerName.h5Store) {
+    return next();
+  }
+  if (to.name === routerName.h5StoreDetail) {
+    return next();
+  }
   if (Object.keys(commonRouterName).includes(to.name as string)) {
     // 跳转通用路由
     return next();
   } else if (isMobile() && !isIPad()) {
+    console.log('当前是移动端');
     if (!Object.keys(mobileRouterName).includes(to.name as string)) {
       // 当前移动端,但是跳转了非移动端路由
       console.log('当前移动端,但是跳转了非移动端路由', to, from);
@@ -291,20 +338,37 @@ router.beforeEach((to, from, next) => {
           params: { roomId: to.params.roomId },
           query: { ...to.query },
         });
+      } else if (to.name === routerName.shop) {
+        return next({
+          name: mobileRouterName.h5Shop,
+          query: { ...to.query },
+        });
+      } else if (to.name === routerName.store) {
+        return next({
+          name: mobileRouterName.h5Store,
+          query: { ...to.query },
+        });
       } else {
         return next({
           name: mobileRouterName.h5,
         });
       }
     } else {
+      if (to.name === routerName.h5Room) {
+        if (!Number(to.params.roomId)) {
+          return next({
+            name: mobileRouterName.h5,
+          });
+        }
+      }
       return next();
     }
   } else {
+    console.log('当前是电脑/ipad端');
     if (Object.keys(mobileRouterName).includes(to.name as string)) {
       // 当前非移动端,但是跳转了移动端路由
       console.log('当前非移动端,但是跳转了移动端路由');
-      if (to.name === mobileRouterName.h5Room) {
-        // 有可能是原生webrtc或srs-webrtc
+      if (to.name === routerName.h5Room) {
         return next({
           name: routerName.home,
         });

+ 1 - 1
src/spec-config.ts

@@ -29,7 +29,7 @@ export const TENCENTCLOUD_COS = {
 export const QINIU_KODO = {
   hssblog: {
     domain: `resource.${prodDomain}`,
-    url: `http://resource.${prodDomain}/`,
+    url: `https://resource.${prodDomain}`,
     bucket: 'hssblog',
     prefix: {
       'billd-live/image/': 'billd-live/image/',

+ 3 - 0
src/store/app/index.ts

@@ -13,6 +13,7 @@ import { ILiveRoom } from '@/types/ILiveRoom';
 export type AppRootState = {
   pageIsClick: boolean;
   useGoogleAd: boolean;
+  usePayCourse: boolean;
   areaList: IArea[];
   playing: boolean;
   videoRatio: number;
@@ -76,6 +77,7 @@ export const useAppStore = defineStore('app', {
     return {
       pageIsClick: false,
       useGoogleAd: false,
+      usePayCourse: false,
       areaList: [],
       playing: false,
       videoRatio: 16 / 9,
@@ -88,6 +90,7 @@ export const useAppStore = defineStore('app', {
       normalVolume: 80,
       navList: [
         { routeName: mobileRouterName.h5, name: '频道' },
+        { routeName: mobileRouterName.h5Shop, name: '商店' },
         { routeName: mobileRouterName.h5Rank, name: '排行' },
         { routeName: mobileRouterName.h5My, name: '我的' },
       ],

+ 4 - 24
src/types/websocket.ts

@@ -1,8 +1,4 @@
-import {
-  DanmuMsgTypeEnum,
-  ILiveUser,
-  WsMessageContentTypeEnum,
-} from '@/interface';
+import { ILiveUser, IWsMessage } from '@/interface';
 import { ILiveRoom, LiveRoomTypeEnum } from '@/types/ILiveRoom';
 import { IUser } from '@/types/IUser';
 
@@ -79,8 +75,8 @@ export interface IReqWsFormat<T> {
   request_id: string;
   /** 用户socket_id */
   socket_id: string;
-  /** 用户信息 */
-  user_info?: IUser;
+  /** 不需要手动传用户代理,从请求头拿 */
+  // user_agent: string;
   /** 用户token */
   user_token?: string;
   /** 消息时间戳 */
@@ -102,8 +98,6 @@ export interface IWsFormat<T> {
   request_id: string;
   /** 用户socket_id */
   socket_id: string;
-  /** 用户信息 */
-  user_info?: IUser;
   /** 用户token */
   user_token?: string;
   /** 消息时间戳 */
@@ -171,22 +165,8 @@ export type WsRemoteDeskBehaviorType = IWsFormat<{
   keyboardtype: string | number;
 }>;
 
-export interface IDanmu {
-  /** 消息类型 */
-  msg_type: DanmuMsgTypeEnum;
-  /** 消息内容类型 */
-  content_type?: WsMessageContentTypeEnum;
-  /** 消息内容 */
-  content: string;
-  live_room_id: number;
-  redbag_send_id?: number;
-  /** 消息id */
-  msg_id?: number;
-  isBilibili?: boolean;
-}
-
 /** ws消息 */
-export type WsMessageType = IWsFormat<IDanmu>;
+export type WsMessageType = IWsFormat<IWsMessage>;
 
 /** 禁言用户 */
 export type WsDisableSpeakingType = IWsFormat<{

+ 21 - 3
src/utils/index.ts

@@ -112,11 +112,29 @@ export async function handleUserMedia({ video, audio }) {
   }
 }
 
-export function formatMoney(money?: number) {
+export function formatPayNum(num: number) {
+  if (num > 10000) {
+    return `${Math.floor(num / 10000)}万+`;
+  } else if (num > 1000) {
+    return `${Math.floor(num / 1000) * 1000}+`;
+  } else if (num > 100) {
+    return `${Math.floor(num / 100) * 100}+`;
+  } else {
+    return num;
+  }
+}
+
+export function formatMoney(money: number, hideZeroCent?: boolean) {
   if (!money) {
-    return '0.00';
+    return hideZeroCent ? '0' : '0.00';
+  }
+  const res = (money / 100).toFixed(2);
+  const res1 = res.split('.');
+  if (hideZeroCent && res1[1] === '00') {
+    return res1[0];
+  } else {
+    return res;
   }
-  return (money / 100).toFixed(2);
 }
 
 export const formatTimeHour = (time: number | string | Date) => {

+ 0 - 1
src/utils/network/webSocket.ts

@@ -72,7 +72,6 @@ export class WebSocketClass {
     const sendData: IReqWsFormat<any> = {
       request_id: requestId,
       socket_id: this.socketIo.id || '',
-      user_info: userStore.userInfo || {},
       user_token: userStore.token || '',
       time: +new Date(),
       data: data || {},

+ 1 - 1
src/views/doc/privatizationDeployment/index.vue

@@ -27,7 +27,7 @@
         :key="index"
       >
         <div class="name">{{ item.name }}</div>
-        <div class="desc">{{ item.desc }}</div>
+        <div class="desc">{{ item.short_desc }}</div>
         <div class="price">
           <span class="t1">{{ item.price.left }}</span>
           <span class="t2">{{ item.price.center }}</span>

+ 14 - 8
src/views/h5/room/index.vue

@@ -289,11 +289,12 @@ import { THEME_COLOR } from '@/constant';
 import { emojiArray } from '@/emoji';
 import { useFullScreen, usePictureInPicture } from '@/hooks/use-play';
 import { usePull } from '@/hooks/use-pull';
+import { useWebsocket } from '@/hooks/use-websocket';
 import {
   DanmuMsgTypeEnum,
   WsMessageContentTypeEnum,
-  WsMessageMsgIsShowEnum,
-  WsMessageMsgIsVerifyEnum,
+  WsMessageIsShowEnum,
+  WsMessageIsVerifyEnum,
 } from '@/interface';
 import router, { mobileRouterName } from '@/router';
 import { useAppStore } from '@/store/app';
@@ -323,8 +324,8 @@ const {
   videoWrapRef,
   handlePlay,
   initPull,
+  initWs,
   keydownDanmu,
-  sendDanmuTxt,
   closeRtc,
   closeWs,
   liveUserList,
@@ -335,9 +336,10 @@ const {
   danmuStr,
   roomLiving,
   videoResolution,
-  initRoomId,
 } = usePull();
 
+const { sendDanmuTxt } = useWebsocket();
+
 onUnmounted(() => {
   closeWs();
   closeRtc();
@@ -346,7 +348,10 @@ onUnmounted(() => {
 });
 
 onMounted(() => {
-  initRoomId(roomId.value);
+  if (!Number(roomId.value)) {
+    return;
+  }
+  initPull({ roomId: roomId.value, autolay: true });
   showPlayBtn.value = true;
   videoWrapRef.value = remoteVideoRef.value;
   setTimeout(() => {
@@ -369,6 +374,7 @@ onMounted(() => {
 
 function handleSendDanmu() {
   sendDanmuTxt(danmuStr.value);
+  danmuStr.value = '';
 }
 
 function handleLogout() {
@@ -386,8 +392,8 @@ async function handleHistoryMsg() {
       orderName: 'created_at',
       orderBy: 'desc',
       live_room_id: Number(roomId.value),
-      is_show: WsMessageMsgIsShowEnum.yes,
-      is_verify: WsMessageMsgIsVerifyEnum.yes,
+      is_show: WsMessageIsShowEnum.yes,
+      is_verify: WsMessageIsVerifyEnum.yes,
     });
     if (res.code === 200) {
       res.data.rows.forEach((v) => {
@@ -488,7 +494,7 @@ async function getLiveRoomInfo() {
         } else {
           showPlayBtn.value = true;
         }
-        initPull({ autolay: autoplayVal.value });
+        initWs({ roomId: roomId.value, isAnchor: false });
       }
     }
   } catch (error) {

+ 289 - 0
src/views/h5/shop/index.vue

@@ -0,0 +1,289 @@
+<template>
+  <div class="shop-wrap">
+    <div class="tab-list">
+      <div
+        v-for="(item, index) in tabList"
+        :key="index"
+        class="tab"
+        :class="{ active: item.key === pageParams.type }"
+        @click="changeTab(item.key)"
+      >
+        {{ item.label }}
+      </div>
+    </div>
+    <div
+      ref="topRef"
+      :style="{ height: height + 'px' }"
+      v-loading="loading"
+    >
+      <LongList
+        ref="longListRef"
+        class="goods-list"
+        @get-list-data="getListData"
+        :rootMargin="{
+          top: 0,
+          bottom: 0,
+          left: 0,
+          right: 0,
+        }"
+        :status="status"
+      >
+        <div
+          v-for="(item, index) in list"
+          :key="index"
+          class="goods"
+          @click="startPay(item)"
+        >
+          <div
+            class="top"
+            v-lazy:background-image="item.cover"
+          >
+            <div
+              v-if="item.badge"
+              class="badge"
+              :style="{ backgroundColor: item.badge_bg }"
+            >
+              <div class="txt">{{ item.badge }}</div>
+            </div>
+          </div>
+          <div class="bottom">
+            <div class="title">
+              <FloatTip
+                :txt="item.name"
+                :max-len="9"
+              ></FloatTip>
+            </div>
+            <div class="price-wrap">
+              <span class="rmb">¥</span>
+              <span class="price">{{ formatMoney(item.price!, true) }}</span>
+              <span class="pay-num">
+                {{ formatPayNum(item.pay_nums!) }}人付款
+              </span>
+            </div>
+          </div>
+        </div>
+      </LongList>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { fetchGoodsList } from '@/api/goods';
+import { URL_QUERY } from '@/constant';
+import { GoodsTypeEnum, IGoods } from '@/interface';
+import router, { routerName } from '@/router';
+import { formatMoney, formatPayNum } from '@/utils';
+
+const route = useRoute();
+const list = ref<IGoods[]>([]);
+const topRef = ref<HTMLDivElement>();
+const loading = ref(false);
+
+const tabList = ref([
+  // { label: '逸鹏的商品', key: GoodsTypeEnum.qypShop },
+  { label: '礼物', key: GoodsTypeEnum.gift },
+  { label: '赞助', key: GoodsTypeEnum.sponsors },
+  { label: '服务', key: GoodsTypeEnum.support },
+]);
+
+const height = ref(-1);
+const hasMore = ref(true);
+
+const pageParams = reactive({
+  nowPage: 0,
+  pageSize: 50,
+  type: tabList.value[0].key,
+  orderName: 'price,created_at',
+  orderBy: 'asc,desc',
+});
+
+const status = ref<'loading' | 'nonedata' | 'allLoaded' | 'normal'>('loading');
+
+function handleStatus() {
+  if (loading.value) {
+    status.value = 'loading';
+  } else if (hasMore.value) {
+    status.value = 'normal';
+  } else {
+    status.value = 'allLoaded';
+  }
+  if (!list.value?.length) {
+    status.value = 'nonedata';
+  }
+}
+
+function getHeight() {
+  if (topRef.value) {
+    height.value =
+      document.documentElement.clientHeight -
+      topRef.value.getBoundingClientRect().top;
+  }
+}
+
+function getListData() {
+  if (!hasMore.value) return;
+  getData();
+}
+
+async function getData(clear = false) {
+  try {
+    loading.value = true;
+    status.value = 'loading';
+    pageParams.nowPage += 1;
+    const res = await fetchGoodsList({
+      ...pageParams,
+    });
+    if (res.code === 200) {
+      if (clear) {
+        list.value = res.data.rows;
+      } else {
+        list.value.push(...res.data.rows);
+      }
+      hasMore.value = res.data.hasMore;
+    }
+  } catch (error) {
+    pageParams.nowPage -= 1;
+    console.log(error);
+  }
+  loading.value = false;
+  status.value = 'normal';
+  handleStatus();
+}
+
+onMounted(() => {
+  getHeight();
+  window.addEventListener('resize', getHeight);
+  const key = route.query[URL_QUERY.goodsType] as GoodsTypeEnum;
+  if (GoodsTypeEnum[key] !== undefined) {
+    pageParams.type = key;
+  } else {
+    router.push({ query: {} });
+  }
+});
+
+onUnmounted(() => {
+  window.removeEventListener('resize', getHeight);
+});
+
+function changeTab(type: GoodsTypeEnum) {
+  pageParams.type = type;
+  pageParams.nowPage = 0;
+  getData(true);
+}
+
+function startPay(item: IGoods) {
+  router.push({ name: routerName.h5ShopDetail, params: { id: item.id } });
+}
+</script>
+
+<style lang="scss" scoped>
+.shop-wrap {
+  padding: 0 20px;
+  background-color: #f9f9f9;
+  .tab-list {
+    display: flex;
+    align-items: center;
+    margin-bottom: 5px;
+    height: 40px;
+    font-size: 14px;
+
+    user-select: none;
+    .tab {
+      position: relative;
+      margin-right: 20px;
+      cursor: pointer;
+      &.active {
+        color: $theme-color-gold;
+        // font-weight: bold;
+
+        &::after {
+          position: absolute;
+          bottom: -6px;
+          left: 50%;
+          width: 20px;
+          height: 3px;
+          border-radius: 10px;
+          background-color: $theme-color-gold;
+          content: '';
+          transform: translateX(-50%);
+        }
+      }
+    }
+  }
+  .goods-list {
+    position: relative;
+    display: flex;
+    align-content: baseline;
+    flex-wrap: wrap;
+    justify-content: space-between;
+    width: calc(100% + 8px);
+    .goods {
+      box-sizing: border-box;
+      margin-bottom: 3%;
+      width: 48.5%;
+      border-radius: 6px;
+      background-color: #fff;
+      box-shadow: 0 4px 10px 0 rgba(238, 242, 245, 0.8);
+      cursor: pointer;
+      transition: box-shadow 0.2s linear;
+      &:hover {
+        box-shadow: 4px 4px 20px 0 rgba(205, 216, 228, 0.6);
+      }
+      .top {
+        position: relative;
+        overflow: hidden;
+        width: 100%;
+        height: 130px;
+
+        @extend %containBg;
+        .badge {
+          position: absolute;
+          top: 0px;
+          right: 0px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          padding: 3px 6px;
+          border-radius: 4px;
+          color: white;
+          .txt {
+            display: inline-block;
+            line-height: 1;
+            transform-origin: center !important;
+
+            @include minFont(12);
+          }
+        }
+      }
+      .bottom {
+        padding: 10px;
+        .title {
+          font-weight: 500;
+          font-size: 16px;
+        }
+        .price-wrap {
+          display: flex;
+          align-items: baseline;
+          margin-top: 8px;
+          color: #ff5000;
+          .rmb {
+            font-size: 16px;
+          }
+          .price {
+            font-weight: 500;
+            font-size: 28px;
+          }
+          .pay-num {
+            margin-left: 5px;
+            color: #999;
+            font-size: 14px;
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 238 - 0
src/views/h5/shopDetail/index.vue

@@ -0,0 +1,238 @@
+<template>
+  <div class="wrap">
+    <div class="nav">
+      <div
+        class="back"
+        @click="router.push({ name: routerName.h5Shop })"
+      >
+        <i class="arrow"></i>
+      </div>
+    </div>
+    <div class="cover-wrap">
+      <img
+        class="cover"
+        :src="goodsInfo?.cover"
+        alt=""
+      />
+    </div>
+    <div class="info-wrap">
+      <div class="price-wrap">
+        <span class="rmb">¥</span>
+        <span class="price">{{ formatMoney(goodsInfo?.price!, true) }}</span>
+        <span
+          class="original-price"
+          v-if="goodsInfo?.original_price !== goodsInfo?.price"
+        >
+          <del>¥{{ formatMoney(goodsInfo?.original_price!, true) }}</del>
+        </span>
+      </div>
+      <div class="name-wrap">{{ goodsInfo?.name }}</div>
+      <div class="other-wrap">
+        <div>库存:{{ goodsInfo?.inventory }}</div>
+        <div>销量:{{ goodsInfo?.pay_nums }}</div>
+      </div>
+    </div>
+    <div class="detail-title">商品详情</div>
+    <RenderMarkdown :md="goodsInfo?.desc"></RenderMarkdown>
+    <div class="buy">
+      <div
+        class="btn"
+        @click="handleBuy"
+      >
+        立即购买
+      </div>
+    </div>
+    <QrPay
+      v-if="showQrPay"
+      :money="qrcodeInfo.money"
+      :goods-id="qrcodeInfo.goodsId"
+      :live-room-id="qrcodeInfo.liveRoomId"
+    ></QrPay>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, onMounted, reactive, ref } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { fetchGoodsFind } from '@/api/goods';
+import { IGoods } from '@/interface';
+import router, { routerName } from '@/router';
+import { formatMoney } from '@/utils';
+
+const route = useRoute();
+
+const goodsId = ref(-1);
+const goodsInfo = ref<IGoods>();
+const showQrPay = ref(false);
+const qrcodeInfo = reactive({
+  money: 0,
+  goodsId: -1,
+  liveRoomId: -1,
+});
+
+onMounted(async () => {
+  goodsId.value = +route.params.id;
+  if (goodsId.value) {
+    const res = await fetchGoodsFind(goodsId.value);
+    if (res.code === 200) {
+      goodsInfo.value = res.data;
+    }
+  }
+});
+function handleBuy() {
+  if (goodsInfo.value?.price! <= 0) {
+    window.$message.info('该商品是免费的,不需要购买!');
+    return;
+  }
+  showQrPay.value = false;
+  nextTick(() => {
+    qrcodeInfo.money = goodsInfo.value?.price!;
+    qrcodeInfo.goodsId = goodsInfo.value?.id!;
+    showQrPay.value = true;
+  });
+}
+</script>
+
+<style lang="scss" scoped>
+.wrap {
+  height: 100vh;
+  background-color: #f9f9f9;
+  .nav {
+    position: fixed;
+    left: 0;
+    z-index: 10;
+    display: flex;
+    align-items: center;
+    box-sizing: border-box;
+    padding-left: 20px;
+    width: 100%;
+    height: 60px;
+    .back {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      box-sizing: border-box;
+      width: 26px;
+      height: 26px;
+      border-radius: 4px;
+      background-color: rgba($color: #000000, $alpha: 0.5);
+      cursor: pointer;
+      .arrow {
+        border-color: white;
+
+        @include arrow(left, 8px, 2px);
+      }
+    }
+  }
+  .cover-wrap {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background-color: #fff;
+    .cover {
+      min-height: 300px;
+      max-width: 100%;
+      max-height: 300px;
+    }
+  }
+  .info-wrap {
+    box-sizing: border-box;
+    margin: 14px auto;
+    padding: 14px 10px;
+    width: 95%;
+    border-radius: 10px;
+    background-color: #fff;
+    .price-wrap {
+      display: flex;
+      align-items: baseline;
+      color: #ff5000;
+      .rmb {
+        font-size: 16px;
+      }
+      .price {
+        font-weight: 500;
+        font-size: 28px;
+      }
+      .original-price {
+        margin-left: 5px;
+        color: #999;
+        font-size: 14px;
+      }
+    }
+    .name-wrap {
+      margin-top: 10px;
+      font-size: 20px;
+    }
+    .other-wrap {
+      display: flex;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      margin-top: 10px;
+      color: #999;
+      font-size: 14px;
+    }
+  }
+  .detail-title {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin: 30px 0;
+    color: #333;
+    text-align: center;
+    font-weight: bold;
+    &::before {
+      display: inline-block;
+      margin: 0 0.625rem;
+      width: 3.0625rem;
+      height: 1px;
+      background: #dcdfe6;
+      content: '';
+    }
+    &::after {
+      display: inline-block;
+      margin: 0 0.625rem;
+      width: 3.0625rem;
+      height: 1px;
+      background: #dcdfe6;
+      content: '';
+    }
+  }
+  .buy {
+    position: fixed;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 54px;
+    background-color: #fff;
+    &::before {
+      position: absolute;
+      top: 0;
+      right: 0;
+      left: 0;
+      z-index: 2;
+      display: block;
+      height: 0;
+      border-top: 1px solid #ddd;
+      content: '';
+      transform: scaleY(0.5);
+      transform-origin: 50% 0;
+    }
+    .btn {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 95%;
+      height: 36px;
+      border-radius: 10px;
+      background-color: $theme-color-gold;
+      color: white;
+      font-size: 15px;
+    }
+  }
+}
+</style>

+ 275 - 0
src/views/h5/store/index.vue

@@ -0,0 +1,275 @@
+<template>
+  <div class="shop-wrap">
+    <div
+      ref="topRef"
+      :style="{ height: height + 'px' }"
+      v-loading="loading"
+    >
+      <LongList
+        ref="longListRef"
+        class="goods-list"
+        @get-list-data="getListData"
+        :rootMargin="{
+          top: 0,
+          bottom: 0,
+          left: 0,
+          right: 0,
+        }"
+        :status="status"
+      >
+        <div
+          v-for="(item, index) in list"
+          :key="index"
+          class="goods"
+          @click="startPay(item)"
+        >
+          <div
+            class="top"
+            v-lazy:background-image="item.cover"
+          >
+            <div
+              v-if="item.badge"
+              class="badge"
+              :style="{ backgroundColor: item.badge_bg }"
+            >
+              <div class="txt">{{ item.badge }}</div>
+            </div>
+          </div>
+          <div class="bottom">
+            <div class="title">
+              <FloatTip
+                :txt="item.name"
+                :max-len="9"
+              ></FloatTip>
+            </div>
+            <div class="price-wrap">
+              <span class="rmb">¥</span>
+              <span class="price">{{ formatMoney(item.price!, true) }}</span>
+              <span class="pay-num">
+                {{ formatPayNum(item.pay_nums!) }}人付款
+              </span>
+            </div>
+          </div>
+        </div>
+      </LongList>
+    </div>
+    <QrPayCpt
+      v-if="showQrPay"
+      :money="goodsInfo.money"
+      :goods-id="goodsInfo.goodsId"
+      :live-room-id="goodsInfo.liveRoomId"
+    ></QrPayCpt>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { fetchGoodsList } from '@/api/goods';
+import QrPayCpt from '@/components/QrPay/index.vue';
+import { URL_QUERY } from '@/constant';
+import { GoodsTypeEnum, IGoods } from '@/interface';
+import router, { routerName } from '@/router';
+import { formatMoney, formatPayNum } from '@/utils';
+
+const route = useRoute();
+const list = ref<IGoods[]>([]);
+const topRef = ref<HTMLDivElement>();
+const loading = ref(false);
+const showQrPay = ref(false);
+const goodsInfo = reactive({
+  money: 0,
+  goodsId: -1,
+  liveRoomId: -1,
+});
+
+// const tabList = ref([
+//   { label: '逸鹏的商品', key: GoodsTypeEnum.qypShop },
+//   { label: '礼物', key: GoodsTypeEnum.gift },
+//   { label: '赞助', key: GoodsTypeEnum.sponsors },
+//   { label: '服务', key: GoodsTypeEnum.support },
+// ]);
+
+const height = ref(-1);
+const hasMore = ref(true);
+
+const pageParams = reactive({
+  nowPage: 0,
+  pageSize: 50,
+  // type: tabList.value[0].key,
+  type: GoodsTypeEnum.qypShop,
+  orderName: 'price,created_at',
+  orderBy: 'asc,desc',
+});
+
+const status = ref<'loading' | 'nonedata' | 'allLoaded' | 'normal'>('loading');
+
+function handleStatus() {
+  if (loading.value) {
+    status.value = 'loading';
+  } else if (hasMore.value) {
+    status.value = 'normal';
+  } else {
+    status.value = 'allLoaded';
+  }
+  if (!list.value?.length) {
+    status.value = 'nonedata';
+  }
+}
+
+function getHeight() {
+  if (topRef.value) {
+    height.value =
+      document.documentElement.clientHeight -
+      topRef.value.getBoundingClientRect().top;
+  }
+}
+
+function getListData() {
+  if (!hasMore.value) return;
+  getData();
+}
+
+async function getData(clear = false) {
+  try {
+    loading.value = true;
+    status.value = 'loading';
+    pageParams.nowPage += 1;
+    const res = await fetchGoodsList({
+      ...pageParams,
+    });
+    if (res.code === 200) {
+      if (clear) {
+        list.value = res.data.rows;
+      } else {
+        list.value.push(...res.data.rows);
+      }
+      hasMore.value = res.data.hasMore;
+    }
+  } catch (error) {
+    pageParams.nowPage -= 1;
+    console.log(error);
+  }
+  loading.value = false;
+  status.value = 'normal';
+  handleStatus();
+}
+
+onMounted(() => {
+  getHeight();
+  window.addEventListener('resize', getHeight);
+  const key = route.query[URL_QUERY.goodsType] as GoodsTypeEnum;
+  if (GoodsTypeEnum[key] !== undefined) {
+    pageParams.type = key;
+  } else {
+    router.push({ query: {} });
+  }
+});
+
+onUnmounted(() => {
+  window.removeEventListener('resize', getHeight);
+});
+
+// function changeTab(type: GoodsTypeEnum) {
+//   showQrPay.value = false;
+//   pageParams.type = type;
+//   pageParams.nowPage = 0;
+//   getData(true);
+// }
+
+function startPay(item: IGoods) {
+  router.push({ name: routerName.h5StoreDetail, params: { id: item.id } });
+  // if (item.price! <= 0) {
+  //   window.$message.info('该商品是免费的,不需要购买!');
+  //   return;
+  // }
+  // showQrPay.value = false;
+  // nextTick(() => {
+  //   goodsInfo.money = item.price!;
+  //   goodsInfo.goodsId = item.id!;
+  //   showQrPay.value = true;
+  // });
+}
+</script>
+
+<style lang="scss" scoped>
+.shop-wrap {
+  box-sizing: border-box;
+  padding: 0 10px;
+  background-color: #f9f9f9;
+  .goods-list {
+    position: relative;
+    display: flex;
+    align-content: baseline;
+    flex-wrap: wrap;
+    justify-content: space-between;
+    padding-top: 10px;
+    width: calc(100% + 4px);
+    .goods {
+      box-sizing: border-box;
+      margin-bottom: 3%;
+      width: 48.5%;
+      border-radius: 6px;
+      background-color: #fff;
+      box-shadow: 0 4px 10px 0 rgba(238, 242, 245, 0.8);
+      cursor: pointer;
+      transition: box-shadow 0.2s linear;
+      &:hover {
+        box-shadow: 4px 4px 20px 0 rgba(205, 216, 228, 0.6);
+      }
+      .top {
+        position: relative;
+        overflow: hidden;
+        width: 100%;
+        height: 130px;
+
+        @extend %containBg;
+        .badge {
+          position: absolute;
+          top: 0px;
+          right: 0px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          padding: 3px 6px;
+          border-radius: 4px;
+          color: white;
+          .txt {
+            display: inline-block;
+            line-height: 1;
+            transform-origin: center !important;
+
+            @include minFont(12);
+          }
+        }
+      }
+      .bottom {
+        padding: 10px;
+        .title {
+          font-weight: 500;
+          font-size: 16px;
+        }
+        .price-wrap {
+          display: flex;
+          align-items: baseline;
+          margin-top: 8px;
+          color: #ff5000;
+          .rmb {
+            font-size: 16px;
+          }
+          .price {
+            font-weight: 500;
+            font-size: 28px;
+          }
+          .pay-num {
+            margin-left: 5px;
+            color: #999;
+            font-size: 14px;
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 170 - 0
src/views/h5/storeDetail/index.vue

@@ -0,0 +1,170 @@
+<template>
+  <div class="wrap">
+    <div class="nav">
+      <div
+        class="back"
+        @click="router.push({ name: routerName.h5Store })"
+      >
+        <i class="arrow"></i>
+      </div>
+    </div>
+    <div class="cover-wrap">
+      <img
+        class="cover"
+        :src="goodsInfo?.cover"
+        alt=""
+      />
+    </div>
+    <div class="info-wrap">
+      <div class="price-wrap">
+        <span class="rmb">¥</span>
+        <span class="price">{{ formatMoney(goodsInfo?.price!, true) }}</span>
+        <span
+          class="original-price"
+          v-if="goodsInfo?.original_price !== goodsInfo?.price"
+        >
+          <del>¥{{ formatMoney(goodsInfo?.original_price!, true) }}</del>
+        </span>
+      </div>
+      <div class="name-wrap">{{ goodsInfo?.name }}</div>
+      <div class="other-wrap">
+        <div>库存:{{ goodsInfo?.inventory }}</div>
+        <div>销量:{{ goodsInfo?.pay_nums }}</div>
+      </div>
+    </div>
+    <div class="detail-title">商品详情</div>
+    <RenderMarkdown :md="goodsInfo?.desc"></RenderMarkdown>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { fetchGoodsFind } from '@/api/goods';
+import { IGoods } from '@/interface';
+import router, { routerName } from '@/router';
+import { formatMoney } from '@/utils';
+
+const route = useRoute();
+
+const goodsId = ref(-1);
+const goodsInfo = ref<IGoods>();
+onMounted(async () => {
+  goodsId.value = +route.params.id;
+  if (goodsId.value) {
+    const res = await fetchGoodsFind(goodsId.value);
+    if (res.code === 200) {
+      goodsInfo.value = res.data;
+    }
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.wrap {
+  height: 100vh;
+  background-color: #f9f9f9;
+  .nav {
+    position: fixed;
+    left: 0;
+    z-index: 10;
+    display: flex;
+    align-items: center;
+    box-sizing: border-box;
+    padding-left: 20px;
+    width: 100%;
+    height: 60px;
+    .back {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      box-sizing: border-box;
+      width: 26px;
+      height: 26px;
+      border-radius: 4px;
+      background-color: rgba($color: #000000, $alpha: 0.5);
+      cursor: pointer;
+      .arrow {
+        border-color: white;
+
+        @include arrow(left, 8px, 2px);
+      }
+    }
+  }
+  .cover-wrap {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background-color: #fff;
+    .cover {
+      max-width: 100%;
+      max-height: 300px;
+      min-height: 300px;
+    }
+  }
+  .info-wrap {
+    box-sizing: border-box;
+    margin: 14px auto;
+    padding: 14px 10px;
+    width: 95%;
+    border-radius: 10px;
+    background-color: #fff;
+    .price-wrap {
+      display: flex;
+      align-items: baseline;
+      color: #ff5000;
+      .rmb {
+        font-size: 16px;
+      }
+      .price {
+        font-weight: 500;
+        font-size: 28px;
+      }
+      .original-price {
+        margin-left: 5px;
+        color: #999;
+        font-size: 14px;
+      }
+    }
+    .name-wrap {
+      margin-top: 10px;
+      font-size: 20px;
+    }
+    .other-wrap {
+      display: flex;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      margin-top: 10px;
+      color: #999;
+      font-size: 14px;
+    }
+  }
+  .detail-title {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin: 30px 0;
+    color: #333;
+    text-align: center;
+    font-weight: bold;
+    &::before {
+      display: inline-block;
+      margin: 0 0.625rem;
+      width: 3.0625rem;
+      height: 1px;
+      background: #dcdfe6;
+      content: '';
+    }
+    &::after {
+      display: inline-block;
+      margin: 0 0.625rem;
+      width: 3.0625rem;
+      height: 1px;
+      background: #dcdfe6;
+      content: '';
+    }
+  }
+}
+</style>

+ 19 - 17
src/views/pull/index.vue

@@ -194,7 +194,7 @@
             </div>
           </div>
           <div class="name">{{ item.name }}</div>
-          <div class="price">¥{{ formatMoney(item.price) }}</div>
+          <div class="price">¥{{ formatMoney(item.price!) }}</div>
         </div>
         <div
           class="item"
@@ -202,7 +202,7 @@
         >
           <div class="ico wallet"></div>
           <div class="name">
-            余额:{{ formatMoney(userStore.userInfo?.wallet?.balance) }}元
+            余额:{{ formatMoney(userStore.userInfo?.wallet?.balance!) }}元
           </div>
           <div class="price">立即充值</div>
         </div>
@@ -464,7 +464,8 @@ import { emojiArray } from '@/emoji';
 import { commentAuthTip, loginTip } from '@/hooks/use-login';
 import { useFullScreen, usePictureInPicture } from '@/hooks/use-play';
 import { usePull } from '@/hooks/use-pull';
-import { useQiniuJsUpload } from '@/hooks/use-upload';
+import { useUpload } from '@/hooks/use-upload';
+import { useWebsocket } from '@/hooks/use-websocket';
 import {
   DanmuMsgTypeEnum,
   GiftRecordStatusEnum,
@@ -474,9 +475,9 @@ import {
   LiveLineEnum,
   LiveRenderEnum,
   WsMessageContentTypeEnum,
-  WsMessageMsgIsFileEnum,
-  WsMessageMsgIsShowEnum,
-  WsMessageMsgIsVerifyEnum,
+  WsMessageIsFileEnum,
+  WsMessageIsShowEnum,
+  WsMessageIsVerifyEnum,
 } from '@/interface';
 import router, { routerName } from '@/router';
 import { QINIU_KODO } from '@/spec-config';
@@ -525,9 +526,6 @@ const {
   closeWs,
   closeRtc,
   keydownDanmu,
-  sendDanmuReward,
-  sendDanmuTxt,
-  sendDanmuImg,
   handlePlay,
   videoWrapRef,
   msgIsFile,
@@ -538,9 +536,11 @@ const {
   damuList,
   liveUserList,
   danmuStr,
-  initRoomId,
 } = usePull();
 
+const { initRoomId, sendDanmuTxt, sendDanmuImg, sendDanmuReward } =
+  useWebsocket();
+
 const rtcRtt = computed(() => {
   const arr: any[] = [];
   networkStore.rtcMap.forEach((rtc) => {
@@ -581,6 +581,7 @@ const rtcBytesReceived = computed(() => {
 
 onMounted(async () => {
   roomId.value = route.params.roomId as string;
+  initPull({ roomId: roomId.value, autolay: true });
   if (route.query[URL_QUERY.isBilibili] === '1') {
     isBilibili.value = true;
     const res = await fetchLiveRoomBilibili();
@@ -614,11 +615,11 @@ onMounted(async () => {
     height.value = res;
   }
   if (isBilibili.value) {
-    initPull({});
+    initWs({ roomId: roomId.value, isBilibili: true, isAnchor: false });
   } else {
     initWs({
       roomId: roomId.value,
-      isBilibili: true,
+      isBilibili: false,
       isAnchor: false,
     });
     initRtcReceive();
@@ -701,6 +702,7 @@ function handleSendGetLiveUser(liveRoomId: number) {
 
 function handleSendDanmu() {
   sendDanmuTxt(danmuStr.value);
+  danmuStr.value = '';
 }
 
 async function getGiftGroupList() {
@@ -740,8 +742,8 @@ async function handleHistoryMsg() {
       orderName: 'created_at',
       orderBy: 'desc',
       live_room_id: Number(roomId.value),
-      is_show: WsMessageMsgIsShowEnum.yes,
-      is_verify: WsMessageMsgIsVerifyEnum.yes,
+      is_show: WsMessageIsShowEnum.yes,
+      is_verify: WsMessageIsVerifyEnum.yes,
     });
     if (res.code === 200) {
       res.data.rows.forEach((v) => {
@@ -898,8 +900,8 @@ async function uploadChange() {
   if (fileList?.length) {
     try {
       msgLoading.value = true;
-      msgIsFile.value = WsMessageMsgIsFileEnum.yes;
-      const res = await useQiniuJsUpload({
+      msgIsFile.value = WsMessageIsFileEnum.yes;
+      const res = await useUpload({
         prefix: QINIU_KODO.hssblog.prefix['billd-live/msg-image/'],
         file: fileList[0],
       });
@@ -909,7 +911,7 @@ async function uploadChange() {
     } catch (error) {
       console.log(error);
     } finally {
-      msgIsFile.value = WsMessageMsgIsFileEnum.no;
+      msgIsFile.value = WsMessageIsFileEnum.no;
       msgLoading.value = false;
       if (uploadRef.value) {
         uploadRef.value.value = '';

+ 16 - 14
src/views/push/index.vue

@@ -500,14 +500,15 @@ import { commentAuthTip, loginTip } from '@/hooks/use-login';
 import { usePush } from '@/hooks/use-push';
 import { useRTCParams } from '@/hooks/use-rtcParams';
 import { useTip } from '@/hooks/use-tip';
-import { useQiniuJsUpload } from '@/hooks/use-upload';
+import { useUpload } from '@/hooks/use-upload';
+import { useWebsocket } from '@/hooks/use-websocket';
 import {
   DanmuMsgTypeEnum,
   MediaTypeEnum,
   WsMessageContentTypeEnum,
-  WsMessageMsgIsFileEnum,
-  WsMessageMsgIsShowEnum,
-  WsMessageMsgIsVerifyEnum,
+  WsMessageIsFileEnum,
+  WsMessageIsShowEnum,
+  WsMessageIsVerifyEnum,
 } from '@/interface';
 import { QINIU_KODO } from '@/spec-config';
 import { AppRootState, useAppStore } from '@/store/app';
@@ -552,7 +553,6 @@ const {
 const {
   startLive,
   endLive,
-  sendDanmu,
   keydownDanmu,
   sendBlob,
   sendRoomNoLive,
@@ -571,6 +571,8 @@ const {
   liveUserList,
 } = usePush();
 
+const { sendDanmuTxt, sendDanmuImg } = useWebsocket();
+
 const addMediaOkMap = ref(new Map());
 const currentMediaType = ref(MediaTypeEnum.camera);
 const currentMediaData = ref<AppRootState['allTrack'][0]>();
@@ -824,7 +826,8 @@ async function changeLiveRoomArea() {
 }
 
 function handleSendDanmu() {
-  sendDanmu();
+  sendDanmuTxt(danmuStr.value);
+  danmuStr.value = '';
 }
 
 function handlePushStr(str) {
@@ -875,19 +878,18 @@ async function uploadChange() {
   if (fileList?.length) {
     try {
       msgLoading.value = true;
-      msgIsFile.value = WsMessageMsgIsFileEnum.yes;
-      const res = await useQiniuJsUpload({
+      msgIsFile.value = WsMessageIsFileEnum.yes;
+      const res = await useUpload({
         prefix: QINIU_KODO.hssblog.prefix['billd-live/msg-image/'],
         file: fileList[0],
       });
       if (res?.resultUrl) {
-        danmuStr.value = res.resultUrl || '错误图片';
-        sendDanmu();
+        sendDanmuImg(res.resultUrl || '错误图片');
       }
     } catch (error) {
       console.log(error);
     } finally {
-      msgIsFile.value = WsMessageMsgIsFileEnum.no;
+      msgIsFile.value = WsMessageIsFileEnum.no;
       msgLoading.value = false;
       if (uploadRef.value) {
         uploadRef.value.value = '';
@@ -1235,8 +1237,8 @@ async function handleHistoryMsg() {
       orderName: 'created_at',
       orderBy: 'desc',
       live_room_id: Number(roomId.value),
-      is_show: WsMessageMsgIsShowEnum.yes,
-      is_verify: WsMessageMsgIsVerifyEnum.yes,
+      is_show: WsMessageIsShowEnum.yes,
+      is_verify: WsMessageIsVerifyEnum.yes,
     });
     if (res.code === 200) {
       res.data.rows.forEach((v) => {
@@ -1324,7 +1326,7 @@ function initAudio() {
 async function uploadLivePreview() {
   const base64 = generateBase64(pushCanvasRef.value!);
   const file = base64ToFile(base64, `tmp.webp`);
-  const uploadRes = await useQiniuJsUpload({
+  const uploadRes = await useUpload({
     prefix: QINIU_KODO.hssblog.prefix['billd-live/live-preview/'],
     file,
   });

+ 168 - 76
src/views/shop/index.vue

@@ -5,133 +5,212 @@
         v-for="(item, index) in tabList"
         :key="index"
         class="tab"
-        :class="{ active: item.key === currTab }"
+        :class="{ active: item.key === pageParams.type }"
         @click="changeTab(item.key)"
       >
         {{ item.label }}
       </div>
     </div>
     <div
+      ref="topRef"
+      :style="{ height: height + 'px' }"
       v-loading="loading"
-      class="goods-list"
     >
-      <div
-        v-for="(item, index) in list"
-        :key="index"
-        class="goods"
-        @click="startPay(item)"
+      <LongList
+        ref="longListRef"
+        class="goods-list"
+        @get-list-data="getListData"
+        :rootMargin="{
+          top: 0,
+          bottom: 0,
+          left: 0,
+          right: 0,
+        }"
+        :status="status"
       >
         <div
-          class="left"
-          v-lazy:background-image="item.cover"
+          v-for="(item, index) in list"
+          :key="index"
+          class="goods"
+          @click="handleBuy(item)"
         >
           <div
-            v-if="item.badge"
-            class="badge"
-            :style="{ backgroundColor: item.badge_bg }"
+            class="top"
+            v-lazy:background-image="item.cover"
           >
-            <div class="txt">{{ item.badge }}</div>
-          </div>
-        </div>
-        <div class="right">
-          <div class="title">{{ item.name }}</div>
-          <div class="info">100%好评</div>
-          <div class="desc">{{ item.desc }}</div>
-          <div class="price-wrap">
-            <span class="price">¥{{ formatMoney(item.price) }}</span>
-            <del
-              v-if="item.price !== item.original_price"
-              class="original-price"
+            <div
+              v-if="item.badge"
+              class="badge"
+              :style="{ backgroundColor: item.badge_bg }"
             >
-              {{ formatMoney(item.original_price) }}
-            </del>
+              <div class="txt">{{ item.badge }}</div>
+            </div>
+          </div>
+          <div class="bottom">
+            <div class="title">
+              <FloatTip
+                :txt="item.name"
+                :max-len="18"
+              ></FloatTip>
+            </div>
+            <div class="price-wrap">
+              <span class="rmb">¥</span>
+              <span class="price">{{ formatMoney(item.price!, true) }}</span>
+              <span
+                class="original-price"
+                v-if="item.original_price !== item.price"
+              >
+                <del>¥{{ formatMoney(item.original_price!, true) }}</del>
+              </span>
+              <span class="pay-num">
+                {{ formatPayNum(item.pay_nums!) }}人付款
+              </span>
+            </div>
           </div>
         </div>
-      </div>
+      </LongList>
     </div>
-    <QrPayCpt
+    <Modal
       v-if="showQrPay"
-      :money="goodsInfo.money"
-      :goods-id="goodsInfo.goodsId"
-      :live-room-id="goodsInfo.liveRoomId"
-    ></QrPayCpt>
+      title="支付"
+      @close="showQrPay = !showQrPay"
+    >
+      <QrPay
+        :money="qrcodeInfo.money"
+        :goods-id="qrcodeInfo.goodsId"
+        :live-room-id="qrcodeInfo.liveRoomId"
+      ></QrPay>
+      <template v-slot:footer></template>
+    </Modal>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, reactive, ref } from 'vue';
+import { nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
 import { useRoute } from 'vue-router';
 
 import { fetchGoodsList } from '@/api/goods';
-import QrPayCpt from '@/components/QrPay/index.vue';
 import { URL_QUERY } from '@/constant';
 import { GoodsTypeEnum, IGoods } from '@/interface';
 import router from '@/router';
-import { formatMoney } from '@/utils';
+import { formatMoney, formatPayNum } from '@/utils';
 
 const route = useRoute();
 const list = ref<IGoods[]>([]);
+const topRef = ref<HTMLDivElement>();
 const loading = ref(false);
-const showQrPay = ref(false);
-const goodsInfo = reactive({
-  money: 0,
-  goodsId: -1,
-  liveRoomId: -1,
-});
 
 const tabList = ref([
+  // { label: '逸鹏的商品', key: GoodsTypeEnum.qypShop },
   { label: '礼物', key: GoodsTypeEnum.gift },
   { label: '赞助', key: GoodsTypeEnum.sponsors },
   { label: '服务', key: GoodsTypeEnum.support },
 ]);
 
-const currTab = ref(tabList.value[0].key);
+const height = ref(-1);
+const hasMore = ref(true);
+
+const showQrPay = ref(false);
+const qrcodeInfo = reactive({
+  money: 0,
+  goodsId: -1,
+  liveRoomId: -1,
+});
+
+const pageParams = reactive({
+  nowPage: 0,
+  pageSize: 100,
+  type: tabList.value[0].key,
+  orderName: 'price,created_at',
+  orderBy: 'asc,desc',
+});
+
+const status = ref<'loading' | 'nonedata' | 'allLoaded' | 'normal'>('loading');
+
+function handleStatus() {
+  if (loading.value) {
+    status.value = 'loading';
+  } else if (hasMore.value) {
+    status.value = 'normal';
+  } else {
+    status.value = 'allLoaded';
+  }
+  if (!list.value?.length) {
+    status.value = 'nonedata';
+  }
+}
+
+function handleBuy(item: IGoods) {
+  console.log('i', item);
+  if (item.price! <= 0) {
+    window.$message.info('该商品是免费的,不需要购买!');
+    return;
+  }
+  showQrPay.value = false;
+  nextTick(() => {
+    qrcodeInfo.money = item.price!;
+    qrcodeInfo.goodsId = item.id!;
+    showQrPay.value = true;
+  });
+}
+
+function getHeight() {
+  if (topRef.value) {
+    height.value =
+      document.documentElement.clientHeight -
+      topRef.value.getBoundingClientRect().top;
+  }
+}
+
+function getListData() {
+  if (!hasMore.value) return;
+  getData();
+}
 
-async function getGoodsList(type: GoodsTypeEnum) {
+async function getData(clear = false) {
   try {
     loading.value = true;
+    status.value = 'loading';
+    pageParams.nowPage += 1;
     const res = await fetchGoodsList({
-      type,
-      orderName: 'price,created_at',
-      orderBy: 'asc,desc',
+      ...pageParams,
     });
     if (res.code === 200) {
-      list.value = res.data.rows;
+      if (clear) {
+        list.value = res.data.rows;
+      } else {
+        list.value.push(...res.data.rows);
+      }
+      hasMore.value = res.data.hasMore;
     }
   } catch (error) {
+    pageParams.nowPage -= 1;
     console.log(error);
-  } finally {
-    loading.value = false;
   }
+  loading.value = false;
+  status.value = 'normal';
+  handleStatus();
 }
 
 onMounted(() => {
+  getHeight();
+  window.addEventListener('resize', getHeight);
   const key = route.query[URL_QUERY.goodsType] as GoodsTypeEnum;
-  if (GoodsTypeEnum[key]) {
-    currTab.value = key;
+  if (GoodsTypeEnum[key] !== undefined) {
+    pageParams.type = key;
   } else {
     router.push({ query: {} });
   }
-  getGoodsList(currTab.value);
 });
 
-function changeTab(type: GoodsTypeEnum) {
-  showQrPay.value = false;
-  currTab.value = type;
-  getGoodsList(currTab.value);
-}
+onUnmounted(() => {
+  window.removeEventListener('resize', getHeight);
+});
 
-function startPay(item: IGoods) {
-  if (item.price! <= 0) {
-    window.$message.info('该商品是免费的,不需要购买!');
-    return;
-  }
-  showQrPay.value = false;
-  nextTick(() => {
-    goodsInfo.money = item.price!;
-    goodsInfo.goodsId = item.id!;
-    showQrPay.value = true;
-  });
+function changeTab(type: GoodsTypeEnum) {
+  pageParams.type = type;
+  pageParams.nowPage = 0;
+  getData(true);
 }
 </script>
 
@@ -171,11 +250,13 @@ function startPay(item: IGoods) {
   .goods-list {
     display: flex;
     flex-wrap: wrap;
+    align-content: baseline;
+    justify-content: space-between;
     .goods {
       display: flex;
       box-sizing: border-box;
-      margin-right: 20px;
-      margin-bottom: 20px;
+      margin-right: 10px;
+      margin-bottom: 15px;
       padding: 18px 10px 10px;
       width: 400px;
       border-radius: 6px;
@@ -185,11 +266,12 @@ function startPay(item: IGoods) {
       &:hover {
         box-shadow: 4px 4px 20px 0 rgba(205, 216, 228, 0.6);
       }
-      .left {
+      .top {
         position: relative;
         margin-right: 20px;
         width: 100px;
         height: 100px;
+        flex-shrink: 0;
         @extend %containBg;
         .badge {
           position: absolute;
@@ -210,20 +292,30 @@ function startPay(item: IGoods) {
           }
         }
       }
-      .right {
+      .bottom {
         .title {
+          margin-top: 10px;
           font-size: 22px;
         }
         .price-wrap {
           display: flex;
-          align-items: center;
+          align-items: baseline;
+          margin-top: 8px;
+          color: $theme-color-gold;
+          .rmb {
+            font-size: 16px;
+          }
           .price {
-            color: $theme-color-gold;
-            font-size: 22px;
+            font-weight: 500;
+            font-size: 28px;
           }
           .original-price {
+            color: #999;
+            font-size: 14px;
+          }
+          .pay-num {
             margin-left: 5px;
-            color: #666;
+            color: #999;
             font-size: 14px;
           }
         }

+ 13 - 0
src/views/store/index.vue

@@ -0,0 +1,13 @@
+<template>
+  <div></div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+
+onMounted(() => {
+  // router.push({ name: routerName.h5Store });
+});
+</script>
+
+<style lang="scss" scoped></style>

+ 2 - 2
src/views/wallet/index.vue

@@ -2,7 +2,7 @@
   <div class="order-wrap">
     <h2 class="title">
       我的钱包:<span class="val">{{
-        formatMoney(userStore.userInfo?.wallet?.balance)
+        formatMoney(userStore.userInfo?.wallet?.balance!)
       }}</span>
     </h2>
@@ -29,7 +29,7 @@
           <span>{{
             item.amount_status === WalletRecordAmountStatusEnum.add ? '+' : '-'
           }}</span>
-          <span>{{ formatMoney(item.amount) }}元</span>
+          <span>{{ formatMoney(item.amount!) }}元</span>
         </div>
       </div>
       <div v-if="!walletRecordList.length">{{ t('common.nonedata') }}</div>