Преглед на файлове

feat: 优化-2024/12/23 13:49:07

shuisheng преди 1 година
родител
ревизия
95c23430d0
променени са 39 файла, в които са добавени 7042 реда и са изтрити 1 реда
  1. 3 1
      deploy/handleSyncPublic.mjs
  2. 71 0
      src/views/about/author/index.vue
  3. 68 0
      src/views/about/group/index.vue
  4. 134 0
      src/views/about/release/index.vue
  5. 295 0
      src/views/about/team/index.vue
  6. 250 0
      src/views/area/id/index.vue
  7. 80 0
      src/views/area/index.vue
  8. 240 0
      src/views/center/index.vue
  9. 7 0
      src/views/center/live-data/index.vue
  10. 25 0
      src/views/center/live-data/overview/index.vue
  11. 25 0
      src/views/center/live-data/record/index.vue
  12. 7 0
      src/views/center/liveroom/index.vue
  13. 490 0
      src/views/center/liveroom/info/index.vue
  14. 293 0
      src/views/center/liveroom/start-live/index.vue
  15. 25 0
      src/views/center/user/income/index.vue
  16. 7 0
      src/views/center/user/index.vue
  17. 25 0
      src/views/center/user/info/index.vue
  18. 142 0
      src/views/doc/ad/index.vue
  19. 31 0
      src/views/doc/api/index.vue
  20. 188 0
      src/views/doc/faq/index.vue
  21. 20 0
      src/views/doc/guide/index.vue
  22. 134 0
      src/views/doc/pushStreamDifferent/index.vue
  23. 174 0
      src/views/download/desk/index.vue
  24. 210 0
      src/views/download/live/index.vue
  25. 826 0
      src/views/home/index.vue
  26. 556 0
      src/views/my/index.vue
  27. 31 0
      src/views/notFound.vue
  28. 118 0
      src/views/oauth/index.vue
  29. 157 0
      src/views/order/index.vue
  30. 582 0
      src/views/privatizationDeployment/index.vue
  31. 629 0
      src/views/rank/index.vue
  32. 328 0
      src/views/shop/index.vue
  33. 284 0
      src/views/sponsors/index.vue
  34. 13 0
      src/views/store/index.vue
  35. 140 0
      src/views/user/index.vue
  36. 215 0
      src/views/videoTools/frameScreenshot/canvas/index.vue
  37. 7 0
      src/views/videoTools/frameScreenshot/webcodec/index.vue
  38. 48 0
      src/views/videoTools/index.vue
  39. 164 0
      src/views/wallet/index.vue

+ 3 - 1
deploy/handleSyncPublic.mjs

@@ -15,7 +15,9 @@ const ignoreIndexOf = [
   path.resolve(proDir, 'src/store'),
   path.resolve(proDir, 'src/types'),
   path.resolve(proDir, 'src/utils'),
-  path.resolve(proDir, 'src/views'),
+  path.resolve(proDir, 'src/views/pull'),
+  path.resolve(proDir, 'src/views/push'),
+  path.resolve(proDir, 'src/views/h5'),
 ];
 
 const dir = fs.readdirSync(proDir).filter((item) => {

+ 71 - 0
src/views/about/author/index.vue

@@ -0,0 +1,71 @@
+<template>
+  <div class="author-wrap">
+    <h1 class="title">作者</h1>
+    <p class="desc">
+      主业前端开发,兴趣使然,故业余时间开发了{{ PROJECT_NAME }}。
+    </p>
+    <div class="hr"></div>
+    <div class="info">
+      <div class="title">微信二维码</div>
+      <img
+        src="@/assets/img/my-wechat.webp"
+        alt=""
+        class="my-wechat"
+      />
+      <p>微信号:{{ AUTHOR_INFO.wechat }}</p>
+      <p>qq号:{{ AUTHOR_INFO.qq }}</p>
+      <b style="color: red">
+        <span>
+          注意:项目开源,个人用户有问题github提Issue即可,不必添加作者!
+        </span>
+        <br />
+        <i>如有商业合作,请充分了解该项目。咨询需付费(100元/小时)!</i>
+      </b>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { AUTHOR_INFO, PROJECT_NAME } from '@/constant';
+</script>
+
+<style lang="scss" scoped>
+.author-wrap {
+  box-sizing: border-box;
+  margin: 0 auto;
+  padding-top: calc($header-height + 10px);
+  width: 960px;
+  height: 100vh;
+  .link {
+    color: $theme-color-gold;
+    text-decoration: none;
+    cursor: pointer;
+  }
+
+  .title {
+    margin: 0;
+    font-weight: 500;
+    font-size: 40px;
+  }
+  .desc {
+    margin: 0;
+    color: #3c3c3cb3;
+    font-size: 16px;
+    line-height: 1.8;
+  }
+  .hr {
+    margin: 60px 0 20px 0;
+    width: 100%;
+    height: 1px;
+    background-color: #e7e7e7;
+  }
+  .info {
+    .title {
+      font-size: 20px;
+    }
+    .my-wechat {
+      height: 500px;
+    }
+  }
+}
+</style>

+ 68 - 0
src/views/about/group/index.vue

@@ -0,0 +1,68 @@
+<template>
+  <div class="group-wrap">
+    <h1 class="title">
+      📢 开源只是业余爱好,还得要<i class="red money">挣钱</i>呀~
+    </h1>
+    <h1 class="title">真正感兴趣(为爱发电)的,无限欢迎提Issue或PR</h1>
+    <h1 class="title">
+      官方群有人但没人说话,不建议进群。想要进群请运行起来该项目!
+    </h1>
+    <p>
+      {{ PROJECT_NAME }}付费课:
+      <span
+        class="link"
+        @click="openToTarget(COMMON_URL.payCoursesArticle)"
+      >
+        {{ COMMON_URL.payCoursesArticle }}
+      </span>
+    </p>
+    <div>微信二维码:</div>
+    <img
+      src="@/assets/img/my-wechat.webp"
+      alt=""
+      style="width: 250px"
+    />
+    <div>微信号:{{ AUTHOR_INFO.wechat }}</div>
+    <div>qq号:{{ AUTHOR_INFO.qq }}</div>
+    <b style="color: red">
+      <span>
+        注意:项目开源,个人用户有问题github提Issue即可,不必添加作者!
+      </span>
+      <br />
+      <i>如有商业合作,请充分了解该项目。咨询需付费(100元/小时)!</i>
+    </b>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { openToTarget } from 'billd-utils';
+
+import { AUTHOR_INFO, COMMON_URL, PROJECT_NAME } from '@/constant';
+</script>
+
+<style lang="scss" scoped>
+.group-wrap {
+  padding-top: calc($header-height + 10px);
+  padding-left: 20px;
+  padding-right: 20px;
+  p {
+    margin: 4px 0;
+  }
+  .title {
+    text-align: center;
+  }
+  .red {
+    color: red;
+  }
+  .money {
+    margin: 0 6px 0 4px;
+  }
+  .link {
+    color: $theme-color-gold;
+    cursor: pointer;
+  }
+  .wechat-group {
+    height: 500px;
+  }
+}
+</style>

+ 134 - 0
src/views/about/release/index.vue

@@ -0,0 +1,134 @@
+<template>
+  <div class="release-wrap">
+    <div class="content">
+      <h1 class="title">版本发布</h1>
+      <div class="hr"></div>
+      <div class="list">
+        <div class="item">
+          <h2>前端版本</h2>
+          <p>提交日期:{{ billd.committerDate }}</p>
+          <p>提交哈希:{{ billd.commitHash }}</p>
+          <p>最后构建:{{ billd.lastBuildDate }}</p>
+        </div>
+        <div class="hr"></div>
+        <div
+          v-loading="loading"
+          class="item"
+        >
+          <h2>后端版本</h2>
+          <p>提交日期:{{ serverInfo?.billd.committerDate }}</p>
+          <p>提交哈希:{{ serverInfo?.billd.commitHash }}</p>
+          <p>最后构建:{{ serverInfo?.billd.lastBuildDate }}</p>
+        </div>
+        <div class="hr"></div>
+        <div
+          v-loading="loading"
+          class="item"
+        >
+          <h2>服务器信息</h2>
+          <p>操作系统:{{ serverInfo?.server.uname }}</p>
+          <p>Node版本:{{ serverInfo?.server.nodeVersion }}</p>
+          <p>Npm版本:{{ serverInfo?.server.npmVersion }}</p>
+          <p>Pnpm版本:{{ serverInfo?.server.pnpmVersion }}</p>
+          <p>Pm2版本:{{ serverInfo?.server.pm2Version }}</p>
+          <p>Docker版本:{{ serverInfo?.server.dockerVersion }}</p>
+          <p>Mysql版本:{{ serverInfo?.server.mysqlVersion }}</p>
+          <p>Redis版本:{{ serverInfo?.server.redisVersion }}</p>
+        </div>
+      </div>
+    </div>
+    <div class="aside">
+      <div class="title">本页目录</div>
+      <div class="item">前端版本</div>
+      <div class="item">后端版本</div>
+      <div class="item">服务器信息</div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref } from 'vue';
+
+import { fetchServerInfo } from '@/api/other';
+import { BilldHtmlWebpackPluginLog, IServerInfo } from '@/interface';
+// @ts-ignore
+const billd: BilldHtmlWebpackPluginLog = process.env.BilldHtmlWebpackPlugin;
+
+const serverInfo = ref<IServerInfo>();
+const loading = ref(false);
+
+async function handleFetchServerInfo() {
+  try {
+    loading.value = true;
+    const res = await fetchServerInfo();
+    if (res.code === 200) {
+      serverInfo.value = res.data;
+    }
+  } catch (error) {
+    console.log(error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+onMounted(() => {
+  handleFetchServerInfo();
+});
+</script>
+
+<style lang="scss" scoped>
+.release-wrap {
+  display: flex;
+  box-sizing: border-box;
+  margin: 0 auto;
+  padding-top: calc($header-height + 10px);
+  width: 960px;
+  color: rgb(33, 53, 71);
+
+  .content {
+    flex: 1;
+    .title {
+      margin: 0;
+      font-weight: 500;
+      font-size: 40px;
+      margin-bottom: 20px;
+    }
+    .hr {
+      margin: 60px 0 20px 0;
+      width: 100%;
+      height: 1px;
+      background-color: #e7e7e7;
+    }
+    .list {
+      h2 {
+        font-weight: 600;
+      }
+      .item {
+        position: relative;
+        font-size: 16px;
+      }
+    }
+  }
+
+  .aside {
+    padding-left: 90px;
+    width: 150px;
+
+    .title {
+      color: rgb(33, 53, 71);
+      font-weight: 700;
+      font-size: 12px;
+      margin-bottom: 8px;
+    }
+    .item {
+      margin-bottom: 8px;
+      color: rgba(60, 60, 60, 0.7);
+      font-size: 13px;
+      cursor: pointer;
+      &:hover {
+        color: #213547;
+      }
+    }
+  }
+}
+</style>

+ 295 - 0
src/views/about/team/index.vue

@@ -0,0 +1,295 @@
+<template>
+  <div class="team-wrap">
+    <h1 class="title">认识团队</h1>
+    <p class="desc">
+      {{
+        PROJECT_NAME
+      }}目前是个人开发,暂时没有贡献者,但以后可能会有,以下是目前部分团队成员的个人信息。
+    </p>
+    <div class="hr"></div>
+    <div class="core-team">
+      <div class="info">
+        <h2 class="title">核心团队成员</h2>
+        <div class="desc">
+          核心团队成员是那些积极参与维护一个或多个核心项目的人。他们对{{
+            PROJECT_NAME
+          }}
+          的生态系统做出了重大贡献,并对项目及其用户的成功做出了长期的承诺。
+        </div>
+      </div>
+      <div class="members">
+        <div
+          v-for="(item, index) in list"
+          :key="index"
+          class="item"
+        >
+          <img
+            v-lazy="'https://www.github.com/galaxy-s10.png'"
+            class="avatar"
+            alt=""
+          />
+          <div class="data">
+            <div class="name">{{ item.name }}</div>
+            <div class="org">{{ item.org }}</div>
+            <div class="profiles">
+              <div class="desc skills">
+                <n-icon size="18">
+                  <CodeOutline></CodeOutline>
+                </n-icon>
+                <div class="txt">
+                  <a
+                    v-for="(iten, indey) in item.skill"
+                    :key="'skill-' + indey"
+                    class="skill link"
+                    :href="iten.github"
+                    @click.prevent="openToTarget(iten.github)"
+                  >
+                    {{ iten.label }}
+                  </a>
+                </div>
+              </div>
+              <div class="desc">
+                <n-icon size="18">
+                  <LocationOutline></LocationOutline>
+                </n-icon>
+                <div class="txt">{{ item.country }}</div>
+              </div>
+              <div class="desc langues">
+                <n-icon size="18">
+                  <GlobeOutline></GlobeOutline>
+                </n-icon>
+                <div class="txt">
+                  <span
+                    v-for="(iten, indey) in item.langues"
+                    :key="'lang-' + indey"
+                    class="langue"
+                  >
+                    {{ iten }}
+                  </span>
+                </div>
+              </div>
+              <div class="desc">
+                <n-icon size="18">
+                  <Link></Link>
+                </n-icon>
+                <a
+                  :href="item.website"
+                  class="txt link"
+                  @click.prevent="openToTarget(item.website)"
+                >
+                  {{ item.website }}
+                </a>
+              </div>
+            </div>
+            <div class="social-list">
+              <a
+                v-for="(iten, indez) in item.social"
+                :key="'social-' + indez"
+                class="social link"
+                :href="iten.github"
+                @click.prevent="openToTarget(iten.github)"
+              >
+                <n-icon
+                  size="22"
+                  class="ico"
+                >
+                  <LogoGithub></LogoGithub>
+                </n-icon>
+              </a>
+            </div>
+          </div>
+          <div
+            class="sponsor"
+            @click="
+              router.push({
+                name: routerName.shop,
+                query: { [URL_QUERY.goodsType]: GoodsTypeEnum.sponsors },
+              })
+            "
+          >
+            <n-icon
+              size="18"
+              class="ico"
+            >
+              <HeartOutline></HeartOutline>
+            </n-icon>
+            赞助
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import {
+  CodeOutline,
+  GlobeOutline,
+  HeartOutline,
+  Link,
+  LocationOutline,
+  LogoGithub,
+} from '@vicons/ionicons5';
+import { openToTarget } from 'billd-utils';
+import { ref } from 'vue';
+
+import { PROJECT_NAME, URL_QUERY } from '@/constant';
+import { GoodsTypeEnum } from '@/interface';
+import router, { routerName } from '@/router';
+import { prodDomain } from '@/spec-config';
+
+const list = ref([
+  {
+    avatar: 'https://www.github.com/galaxy-s10.png',
+    name: 'galaxy-s10',
+    org: 'Creator @billd-live',
+    country: 'Guangzho, China',
+    langues: ['中文'],
+    skill: [
+      {
+        label: 'billd-live',
+        github: 'https://github.com/billd-project/live',
+      },
+      {
+        label: 'billd-live-admin',
+        github: 'https://github.com/billd-project/live-admin',
+      },
+      {
+        label: 'billd-live-server',
+        github: 'https://github.com/billd-project/live-server',
+      },
+    ],
+    social: [{ github: 'https://www.github.com/galaxy-s10' }],
+    website: `https://www.${prodDomain}`,
+  },
+]);
+</script>
+
+<style lang="scss" scoped>
+.team-wrap {
+  box-sizing: border-box;
+  margin: 0 auto;
+  padding-top: calc($header-height + 10px);
+  width: 960px;
+  height: 100vh;
+  .link {
+    color: $theme-color-gold;
+    text-decoration: none;
+    cursor: pointer;
+  }
+
+  .title {
+    margin: 0;
+    font-weight: 500;
+    font-size: 40px;
+  }
+  .desc {
+    margin: 0;
+    color: #3c3c3cb3;
+    font-size: 16px;
+    line-height: 1.8;
+  }
+  .hr {
+    margin: 60px 0 20px 0;
+    width: 100%;
+    height: 1px;
+    background-color: #e7e7e7;
+  }
+  .core-team {
+    display: flex;
+    justify-content: space-between;
+    padding-bottom: 30px;
+    .info {
+      .title {
+        font-size: 20px;
+      }
+      .desc {
+        box-sizing: border-box;
+        margin-top: 3px;
+        padding-right: 60px;
+        width: 300px;
+        font-size: 14px;
+        line-height: 1.8;
+      }
+    }
+    .members {
+      flex-grow: 0.8;
+      .item {
+        position: relative;
+        display: flex;
+        padding: 30px 0;
+        border-radius: 10px;
+        background-color: #f9f9f9;
+        .avatar {
+          margin-left: 30px;
+          width: 80px;
+          height: 80px;
+          border-radius: 50%;
+        }
+        .data {
+          margin-left: 30px;
+          .name {
+            margin-bottom: 2px;
+            font-size: 20px;
+          }
+          .org {
+            margin-bottom: 10px;
+            font-size: 14px;
+          }
+          .profiles {
+            margin-bottom: 10px;
+
+            .desc {
+              display: flex;
+              align-items: center;
+              margin-bottom: 7px;
+              .txt {
+                margin-left: 10px;
+              }
+              &.langues,
+              &.skills {
+                .langue:not(:last-child),
+                .skill:not(:last-child) {
+                  &:after {
+                    margin: 0 7px;
+                    color: #3c3c3c54;
+                    content: '•';
+                    font-size: 14px;
+                  }
+                }
+              }
+            }
+          }
+          .social-list {
+            padding-top: 2px;
+            .social {
+              color: #747474;
+              cursor: pointer;
+            }
+          }
+        }
+        .sponsor {
+          position: absolute;
+          top: 20px;
+          right: 20px;
+          display: flex;
+          align-items: center;
+          padding: 4px 8px;
+          border: 1px solid $theme-color-gold;
+          border-radius: 4px;
+          color: $theme-color-gold;
+          cursor: pointer;
+          transition: all 0.3s ease;
+          &:hover {
+            background-color: $theme-color-gold;
+            color: white;
+          }
+          .ico {
+            margin-right: 4px;
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 250 - 0
src/views/area/id/index.vue

@@ -0,0 +1,250 @@
+<template>
+  <div
+    ref="topRef"
+    class="area-wrap"
+    :style="{ height: height + 'px' }"
+  >
+    <LongList
+      v-if="height > 0"
+      ref="longListRef"
+      class="list"
+      :rootMargin="{
+        top: 0,
+        bottom: 100,
+        left: 0,
+        right: 0,
+      }"
+      :status="status"
+      @get-list-data="getListData"
+    >
+      <div
+        v-for="(item, index) in liveRoomList"
+        :key="index"
+        class="item"
+        @click="goRoom(item)"
+      >
+        <div
+          v-lazy:background-image="item?.cover_img || item?.users?.[0]?.avatar"
+          class="cover"
+        >
+          <div
+            v-if="item?.live"
+            class="living-ico"
+          >
+            直播中
+          </div>
+          <div
+            v-if="
+              item?.cdn === SwitchEnum.yes ||
+              [
+                LiveRoomTypeEnum.tencent_css,
+                LiveRoomTypeEnum.tencent_css_pk,
+              ].includes(item.type!)
+            "
+            class="cdn-ico"
+          >
+            <div class="txt">CDN</div>
+          </div>
+          <div class="txt">{{ item?.users?.[0]?.username }}</div>
+        </div>
+        <div class="desc">{{ item?.name }}</div>
+      </div>
+    </LongList>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { openToTarget } from 'billd-utils';
+import { onMounted, reactive, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { fetchLiveRoomList } from '@/api/area';
+import LongList from '@/components/LongList/index.vue';
+import { SwitchEnum } from '@/interface';
+import router, { routerName } from '@/router';
+import { ILiveRoom, LiveRoomTypeEnum } from '@/types/ILiveRoom';
+
+const liveRoomList = ref<ILiveRoom[]>([]);
+const route = useRoute();
+const status = ref<'loading' | 'nonedata' | 'allLoaded' | 'normal'>('loading');
+
+const longListRef = ref<InstanceType<typeof LongList>>();
+const topRef = ref<HTMLDivElement>();
+const height = ref(-1);
+const loading = ref(false);
+const hasMore = ref(true);
+const pageParams = reactive({
+  nowPage: 0,
+  pageSize: 50,
+});
+
+watch(
+  () => route.params.id,
+  (newVal) => {
+    if (!newVal) return;
+    liveRoomList.value = [];
+    pageParams.nowPage = 0;
+    getData();
+  }
+);
+
+function handleStatus() {
+  if (loading.value) {
+    status.value = 'loading';
+  } else if (hasMore.value) {
+    status.value = 'normal';
+  } else {
+    status.value = 'allLoaded';
+  }
+  if (!liveRoomList.value?.length) {
+    status.value = 'nonedata';
+  }
+}
+
+function goRoom(item: ILiveRoom) {
+  if (!item.live) {
+    window.$message.info('该直播间没在直播~');
+    return;
+  }
+  const url = router.resolve({
+    name: routerName.pull,
+    params: { roomId: item.id },
+  });
+  openToTarget(url.href);
+}
+
+onMounted(() => {
+  if (topRef.value) {
+    height.value =
+      document.documentElement.clientHeight -
+      topRef.value.getBoundingClientRect().top;
+  }
+  getData();
+});
+
+function getListData() {
+  if (!hasMore.value) return;
+  getData();
+}
+
+async function getData() {
+  try {
+    if (loading.value) return;
+    loading.value = true;
+    status.value = 'loading';
+    pageParams.nowPage += 1;
+    const res = await fetchLiveRoomList({
+      id: Number(route.params.id),
+      live_room_is_show: SwitchEnum.yes,
+      nowPage: pageParams.nowPage,
+      pageSize: pageParams.pageSize,
+    });
+    if (res.code === 200) {
+      liveRoomList.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();
+}
+</script>
+
+<style lang="scss" scoped>
+.area-wrap {
+  padding: 0 20px;
+
+  .list {
+    display: flex;
+    align-content: flex-start;
+    flex-wrap: wrap;
+    justify-content: space-between;
+    .item {
+      display: inline-block;
+      margin-right: 25px;
+      margin-bottom: 12px;
+      width: 300px;
+      cursor: pointer;
+      .cover {
+        position: relative;
+        overflow: hidden;
+        width: 100%;
+        height: 150px;
+        border-radius: 8px;
+
+        @extend %containBg;
+        .living-ico {
+          position: absolute;
+          top: 0px;
+          left: 0px;
+          z-index: 10;
+          padding: 0 10px;
+          height: 20px;
+          border-radius: 8px 0 10px;
+          background-color: $theme-color-gold;
+          color: white;
+          text-align: center;
+          font-size: 12px;
+          line-height: 20px;
+        }
+        .cdn-ico {
+          position: absolute;
+          top: -10px;
+          right: -10px;
+          z-index: 2;
+          width: 70px;
+          height: 28px;
+          background-color: #f87c48;
+          color: white;
+          transform: rotate(45deg);
+          transform-origin: bottom;
+          .txt {
+            margin-left: 18px;
+            background-image: initial !important;
+            font-size: 13px;
+          }
+        }
+
+        .txt {
+          position: absolute;
+          bottom: 0;
+          left: 0;
+          box-sizing: border-box;
+          padding: 4px 8px;
+          width: 100%;
+          border-radius: 0 0 4px 4px;
+          background-image: linear-gradient(
+            -180deg,
+            rgba(0, 0, 0, 0),
+            rgba(0, 0, 0, 0.6)
+          );
+          color: white;
+          text-align: initial;
+          font-size: 13px;
+
+          @extend %singleEllipsis;
+        }
+      }
+      .desc {
+        margin-top: 4px;
+        font-size: 14px;
+
+        @extend %singleEllipsis;
+      }
+    }
+    .null {
+      width: 100%;
+      text-align: center;
+    }
+  }
+  .paging-wrap {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-bottom: 20px;
+  }
+}
+</style>

+ 80 - 0
src/views/area/index.vue

@@ -0,0 +1,80 @@
+<template>
+  <div class="tab-list">
+    <div
+      v-for="(item, index) in appStore.areaList"
+      :key="index"
+      class="tab"
+      :class="{ active: router.currentRoute.value.params.id === item.id + '' }"
+      @click.prevent="changeArea(item)"
+    >
+      {{ item.name }}
+    </div>
+    <div v-if="!appStore.areaList.length">暂无分区</div>
+  </div>
+
+  <router-view></router-view>
+</template>
+
+<script lang="ts" setup>
+import { watch } from 'vue';
+
+import { IArea } from '@/interface';
+import router, { routerName } from '@/router';
+import { useAppStore } from '@/store/app';
+
+const appStore = useAppStore();
+
+function changeArea(item: IArea) {
+  router.push({ name: routerName.areaDetail, params: { id: item.id } });
+}
+
+watch(
+  () => appStore.areaList,
+  (newval) => {
+    if (newval.length) {
+      router.push({
+        name: routerName.areaDetail,
+        params: { id: appStore.areaList[0].id },
+      });
+    }
+  },
+  {
+    immediate: true,
+    deep: true,
+  }
+);
+</script>
+
+<style lang="scss" scoped>
+.tab-list {
+  display: flex;
+  align-items: center;
+  margin-bottom: 10px;
+  height: 40px;
+  font-size: 14px;
+  padding-top: calc($header-height + 10px);
+  padding-left: 20px;
+  padding-right: 20px;
+  user-select: none;
+  .tab {
+    position: relative;
+    margin-right: 20px;
+    cursor: pointer;
+    &.active {
+      color: $theme-color-gold;
+
+      &::after {
+        position: absolute;
+        bottom: -6px;
+        left: 50%;
+        width: 20px;
+        height: 2px;
+        border-radius: 10px;
+        background-color: $theme-color-gold;
+        content: '';
+        transform: translateX(-50%);
+      }
+    }
+  }
+}
+</style>

+ 240 - 0
src/views/center/index.vue

@@ -0,0 +1,240 @@
+<template>
+  <div class="center-wrap">
+    <div class="center">
+      <div class="sidebar">
+        <div
+          class="sidebar-item"
+          :class="{
+            show: route.matched.find((v) => v.name === routerName.centerUser),
+          }"
+          @click="router.push({ name: routerName.centerUserInfo })"
+        >
+          <div class="panel">
+            <div class="label">
+              <div class="ico user"></div>
+              <div class="name">用户中心</div>
+            </div>
+            <div class="arrow"></div>
+          </div>
+
+          <div class="list">
+            <div
+              class="list-item"
+              :class="{
+                active: route.name === routerName.centerUserInfo,
+              }"
+              @click.stop="router.push({ name: routerName.centerUserInfo })"
+            >
+              个人信息
+            </div>
+            <div
+              class="list-item"
+              :class="{
+                active: route.name === routerName.centerUserIncome,
+              }"
+              @click.stop="router.push({ name: routerName.centerUserIncome })"
+            >
+              个人收益
+            </div>
+          </div>
+        </div>
+        <div
+          class="sidebar-item"
+          :class="{
+            show: route.matched.find(
+              (v) => v.name === routerName.centerLiveRoom
+            ),
+          }"
+          @click="router.push({ name: routerName.centerLiveRoomInfo })"
+        >
+          <div class="panel">
+            <div class="label">
+              <div class="ico camera"></div>
+              <div class="name">我的直播间</div>
+            </div>
+            <div class="arrow"></div>
+          </div>
+
+          <div class="list">
+            <div
+              class="list-item"
+              :class="{
+                active: route.name === routerName.centerLiveRoomInfo,
+              }"
+              @click.stop="router.push({ name: routerName.centerLiveRoomInfo })"
+            >
+              直播间信息
+            </div>
+            <div
+              class="list-item"
+              :class="{
+                active: route.name === routerName.centerLiveRoomStartLive,
+              }"
+              @click.stop="
+                router.push({ name: routerName.centerLiveRoomStartLive })
+              "
+            >
+              开播设置
+            </div>
+          </div>
+        </div>
+        <div
+          class="sidebar-item"
+          :class="{
+            show: route.matched.find(
+              (v) => v.name === routerName.centerLiveData
+            ),
+          }"
+          @click="router.push({ name: routerName.centerLiveDataOverview })"
+        >
+          <div class="panel">
+            <div class="label">
+              <div class="ico line_chart"></div>
+              <div class="name">直播数据</div>
+            </div>
+            <div class="arrow"></div>
+          </div>
+
+          <div class="list">
+            <div
+              class="list-item"
+              :class="{
+                active: route.name === routerName.centerLiveDataOverview,
+              }"
+              @click.stop="
+                router.push({ name: routerName.centerLiveDataOverview })
+              "
+            >
+              数据总览
+            </div>
+            <div
+              class="list-item"
+              :class="{
+                active: route.name === routerName.centerLiveDataRecord,
+              }"
+              @click.stop="
+                router.push({ name: routerName.centerLiveDataRecord })
+              "
+            >
+              场次数据
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="view">
+        <router-view />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import { useRoute } from 'vue-router';
+
+import router, { routerName } from '@/router';
+
+const route = useRoute();
+
+onMounted(() => {
+  console.log(route.matched);
+});
+</script>
+
+<style lang="scss" scoped>
+.center-wrap {
+  display: flex;
+  padding-top: $header-height;
+  width: 100vw;
+  height: calc(100vh - $header-height);
+  background-color: #f3f3f7;
+
+  .center {
+    display: flex;
+    justify-content: space-between;
+    margin: 20px auto 0;
+    width: 1200px;
+    .sidebar {
+      padding: 10px 0;
+      height: 40vh;
+      border: 1px solid #e9eaec;
+      border-radius: 12px;
+      background-color: white;
+      font-size: 16px;
+      .sidebar-item {
+        position: relative;
+        width: 190px;
+        .panel {
+          display: flex;
+          align-items: center;
+          justify-content: space-between;
+          padding: 0 20px;
+          height: 55px;
+          border-bottom: 1px solid #e9eaec;
+          cursor: pointer;
+          .label {
+            display: flex;
+            align-items: center;
+            .ico {
+              margin-right: 8px;
+              width: 20px;
+              height: 20px;
+              &.user {
+                @include setBackground('@/assets/img/user.png');
+              }
+              &.line_chart {
+                @include setBackground('@/assets/img/line_chart.png');
+              }
+              &.camera {
+                @include setBackground('@/assets/img/camera.png');
+              }
+            }
+            .name {
+            }
+          }
+          .arrow {
+            border-color: #8e8e8e;
+
+            @include arrow(bottom, 6px, 1px);
+          }
+        }
+        &.show {
+          .label {
+            color: $theme-color-gold;
+          }
+          .arrow {
+            transform: rotate(180deg);
+          }
+          .list {
+            height: auto;
+          }
+        }
+
+        .list {
+          overflow: hidden;
+          box-sizing: border-box;
+          width: 100%;
+          height: 0;
+          font-size: 12px;
+          .list-item {
+            padding-left: 47px;
+            height: 40px;
+            line-height: 40px;
+            cursor: pointer;
+            &.active {
+              color: $theme-color-gold;
+            }
+            &:hover {
+              color: $theme-color-gold;
+            }
+          }
+        }
+      }
+    }
+    .view {
+      flex: 1;
+      padding-left: 20px;
+    }
+  }
+}
+</style>

+ 7 - 0
src/views/center/live-data/index.vue

@@ -0,0 +1,7 @@
+<template>
+  <router-view></router-view>
+</template>
+
+<script lang="ts" setup></script>
+
+<style lang="scss" scoped></style>

+ 25 - 0
src/views/center/live-data/overview/index.vue

@@ -0,0 +1,25 @@
+<template>
+  <div class="wrap">
+    <div class="title">数据总览</div>
+    <div class="card"></div>
+  </div>
+</template>
+
+<script lang="ts" setup></script>
+
+<style lang="scss" scoped>
+.wrap {
+  .title {
+    font-size: 24px;
+    color: #333;
+    margin-bottom: 20px;
+  }
+  .card {
+    border: 1px solid #e9eaec;
+    border-radius: 12px;
+    background-color: white;
+    background-color: white;
+    height: 200px;
+  }
+}
+</style>

+ 25 - 0
src/views/center/live-data/record/index.vue

@@ -0,0 +1,25 @@
+<template>
+  <div class="wrap">
+    <div class="title">场次数据</div>
+    <div class="card"></div>
+  </div>
+</template>
+
+<script lang="ts" setup></script>
+
+<style lang="scss" scoped>
+.wrap {
+  .title {
+    font-size: 24px;
+    color: #333;
+    margin-bottom: 20px;
+  }
+  .card {
+    border: 1px solid #e9eaec;
+    border-radius: 12px;
+    background-color: white;
+    background-color: white;
+    height: 200px;
+  }
+}
+</style>

+ 7 - 0
src/views/center/liveroom/index.vue

@@ -0,0 +1,7 @@
+<template>
+  <router-view></router-view>
+</template>
+
+<script lang="ts" setup></script>
+
+<style lang="scss" scoped></style>

+ 490 - 0
src/views/center/liveroom/info/index.vue

@@ -0,0 +1,490 @@
+<template>
+  <div class="wrap">
+    <div class="title">直播间信息</div>
+    <div
+      v-if="liveRoomInfo"
+      class="card"
+    >
+      <div class="pull-url">
+        <span
+          v-if="!userStore.userInfo?.live_rooms?.length"
+          class="link"
+          @click="openLiveRoom"
+        >
+          未开通
+        </span>
+        <div v-else>
+          <div>
+            直播间ID:
+            <a
+              :href="getLiveRoomPageUrl(liveRoomInfo.id!)"
+              class="link"
+              target="_blank"
+            >
+              {{ liveRoomInfo.id }}
+            </a>
+          </div>
+          <div>直播间名称:{{ liveRoomInfo.name }}</div>
+          <div>直播间简介:{{ liveRoomInfo.desc || '暂无简介' }}</div>
+          <div>
+            直播间分区:{{ liveRoomInfo.areas?.[0]?.name || '暂无分区' }}
+          </div>
+          <div>开通时间:{{ liveRoomInfo.created_at }}</div>
+
+          <div
+            v-if="
+              userStore.userInfo?.auths?.find(
+                (v) => v.auth_value === DEFAULT_AUTH_INFO.LIVE_PUSH.auth_value
+              )
+            "
+            v-loading="updateKeyLoading"
+            class="url-wrap"
+          >
+            <div
+              class="link"
+              @click="handleUpdateKey"
+            >
+              更新地址
+            </div>
+
+            <div class="srs">
+              <div>
+                <span> RTMP推流地址:{{ liveRoomInfo.push_rtmp_url! }}, </span>
+                <span
+                  class="link"
+                  @click="handleCopy(liveRoomInfo.push_rtmp_url!)"
+                >
+                  复制
+                </span>
+              </div>
+              <div>
+                <span>OBS服务器:{{ liveRoomInfo.push_obs_server! }},</span>
+                <span
+                  class="link"
+                  @click="handleCopy(liveRoomInfo.push_obs_server!)"
+                >
+                  复制
+                </span>
+              </div>
+              <div>
+                <span
+                  >OBS推流码:{{ liveRoomInfo.push_obs_stream_key! }},</span
+                >
+                <span
+                  class="link"
+                  @click="handleCopy(liveRoomInfo.push_obs_stream_key!)"
+                >
+                  复制
+                </span>
+              </div>
+            </div>
+
+            <br />
+
+            <div>
+              CDN直播:
+              <div
+                v-if="
+                  userStore.userInfo?.auths?.find(
+                    (v) =>
+                      v.auth_value ===
+                      DEFAULT_AUTH_INFO.LIVE_PULL_SVIP.auth_value
+                  )
+                "
+                class="cdn"
+              >
+                <div>
+                  <span>
+                    RTMP推流地址(CDN):{{ liveRoomInfo.push_cdn_rtmp_url! }},
+                  </span>
+                  <span
+                    class="link"
+                    @click="handleCopy(liveRoomInfo.push_cdn_rtmp_url!)"
+                  >
+                    复制
+                  </span>
+                </div>
+                <div>
+                  <span>
+                    OBS服务器(CDN):{{ liveRoomInfo.push_cdn_obs_server! }},
+                  </span>
+                  <span
+                    class="link"
+                    @click="handleCopy(liveRoomInfo.push_cdn_obs_server!)"
+                  >
+                    复制
+                  </span>
+                </div>
+                <div>
+                  <span>
+                    OBS推流码(CDN):{{
+                      liveRoomInfo.push_cdn_obs_stream_key!
+                    }},
+                  </span>
+                  <span
+                    class="link"
+                    @click="handleCopy(liveRoomInfo.push_cdn_obs_stream_key!)"
+                  >
+                    复制
+                  </span>
+                </div>
+              </div>
+              <div
+                v-else
+                class="link"
+                @click="router.push({ name: routerName.author })"
+              >
+                请联系作者开通~
+              </div>
+            </div>
+
+            <div>
+              转推b站:
+              <div
+                v-if="
+                  userStore.userInfo?.auths?.find(
+                    (v) =>
+                      v.auth_value ===
+                      DEFAULT_AUTH_INFO.LIVE_PUSH_FORWARD_BILIBILI.auth_value
+                  )
+                "
+                class="cdn"
+              >
+                <n-input-group>
+                  <n-input
+                    v-model:value="liveRoomInfo.forward_bilibili_url"
+                    style="width: 500px"
+                    type="text"
+                    placeholder="请输入转推b站url"
+                  />
+
+                  <n-button
+                    type="primary"
+                    ghost
+                    @click="handleUpdateMyLiveRoom()"
+                  >
+                    更新
+                  </n-button>
+                </n-input-group>
+              </div>
+              <div
+                v-else
+                class="link"
+                @click="router.push({ name: routerName.author })"
+              >
+                请联系作者开通~
+              </div>
+            </div>
+
+            <div>
+              转推虎牙:
+              <div
+                v-if="
+                  userStore.userInfo?.auths?.find(
+                    (v) =>
+                      v.auth_value ===
+                      DEFAULT_AUTH_INFO.LIVE_PUSH_FORWARD_HUYA.auth_value
+                  )
+                "
+                class="cdn"
+              >
+                <n-input-group>
+                  <n-input
+                    v-model:value="liveRoomInfo.forward_huya_url"
+                    style="width: 500px"
+                    type="text"
+                    placeholder="请输入转推虎牙url"
+                  />
+
+                  <n-button
+                    type="primary"
+                    ghost
+                    @click="handleUpdateMyLiveRoom()"
+                  >
+                    更新
+                  </n-button>
+                </n-input-group>
+              </div>
+              <div
+                v-else
+                class="link"
+                @click="router.push({ name: routerName.author })"
+              >
+                请联系作者开通~
+              </div>
+            </div>
+
+            <div>
+              转推斗鱼:
+              <div
+                v-if="
+                  userStore.userInfo?.auths?.find(
+                    (v) =>
+                      v.auth_value ===
+                      DEFAULT_AUTH_INFO.LIVE_PUSH_FORWARD_DOUYU.auth_value
+                  )
+                "
+                class="cdn"
+              >
+                <n-input-group>
+                  <n-input
+                    v-model:value="liveRoomInfo.forward_douyu_url"
+                    style="width: 500px"
+                    type="text"
+                    placeholder="请输入转推斗鱼url"
+                  />
+
+                  <n-button
+                    type="primary"
+                    ghost
+                    @click="handleUpdateMyLiveRoom()"
+                  >
+                    更新
+                  </n-button>
+                </n-input-group>
+              </div>
+              <div
+                v-else
+                class="link"
+                @click="router.push({ name: routerName.author })"
+              >
+                请联系作者开通~
+              </div>
+            </div>
+
+            <div>
+              转推抖音:
+              <div
+                v-if="
+                  userStore.userInfo?.auths?.find(
+                    (v) =>
+                      v.auth_value ===
+                      DEFAULT_AUTH_INFO.LIVE_PUSH_FORWARD_DOUYIN.auth_value
+                  )
+                "
+                class="cdn"
+              >
+                <n-input-group>
+                  <n-input
+                    v-model:value="liveRoomInfo.forward_douyin_url"
+                    style="width: 500px"
+                    type="text"
+                    placeholder="请输入转推抖音url"
+                  />
+
+                  <n-button
+                    type="primary"
+                    ghost
+                    @click="handleUpdateMyLiveRoom()"
+                  >
+                    更新
+                  </n-button>
+                </n-input-group>
+              </div>
+              <div
+                v-else
+                class="link"
+                @click="router.push({ name: routerName.author })"
+              >
+                请联系作者开通~
+              </div>
+            </div>
+
+            <div>
+              转推小红书:
+              <div
+                v-if="
+                  userStore.userInfo?.auths?.find(
+                    (v) =>
+                      v.auth_value ===
+                      DEFAULT_AUTH_INFO.LIVE_PUSH_FORWARD_XIAOHONGSHU.auth_value
+                  )
+                "
+                class="cdn"
+              >
+                <n-input-group>
+                  <n-input
+                    v-model:value="liveRoomInfo.forward_xiaohongshu_url"
+                    style="width: 500px"
+                    type="text"
+                    placeholder="请输入转推小红书url"
+                  />
+
+                  <n-button
+                    type="primary"
+                    ghost
+                    @click="handleUpdateMyLiveRoom()"
+                  >
+                    更新
+                  </n-button>
+                </n-input-group>
+              </div>
+              <div
+                v-else
+                class="link"
+                @click="router.push({ name: routerName.author })"
+              >
+                请联系作者开通~
+              </div>
+            </div>
+
+            <div>
+              转推快手:
+              <div
+                v-if="
+                  userStore.userInfo?.auths?.find(
+                    (v) =>
+                      v.auth_value ===
+                      DEFAULT_AUTH_INFO.LIVE_PUSH_FORWARD_KUAISHOU.auth_value
+                  )
+                "
+                class="cdn"
+              >
+                <n-input-group>
+                  <n-input
+                    v-model:value="liveRoomInfo.forward_kuaishou_url"
+                    style="width: 500px"
+                    type="text"
+                    placeholder="请输入转推快手url"
+                  />
+
+                  <n-button
+                    type="primary"
+                    ghost
+                    @click="handleUpdateMyLiveRoom()"
+                  >
+                    更新
+                  </n-button>
+                </n-input-group>
+              </div>
+              <div
+                v-else
+                class="link"
+                @click="router.push({ name: routerName.author })"
+              >
+                请联系作者开通~
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { copyToClipBoard, openToTarget } from 'billd-utils';
+import { ref, toRefs, watch } from 'vue';
+
+import { fetchUpdateLiveRoomKey, fetchUpdateMyLiveRoom } from '@/api/liveRoom';
+import { DEFAULT_AUTH_INFO, URL_QUERY } from '@/constant';
+import { loginTip } from '@/hooks/use-login';
+import router, { routerName } from '@/router';
+import { useUserStore } from '@/store/user';
+import { ILiveRoom, LiveRoomTypeEnum } from '@/types/ILiveRoom';
+import { getLiveRoomPageUrl } from '@/utils';
+
+const userStore = useUserStore();
+const { userInfo } = toRefs(userStore);
+const liveRoomInfo = ref<ILiveRoom>();
+const updateKeyLoading = ref(false);
+
+watch(
+  () => userInfo?.value,
+  (newval) => {
+    console.log('newval', newval);
+    const res = newval?.live_rooms?.[0];
+    if (res) {
+      liveRoomInfo.value = res;
+    }
+  },
+  {
+    immediate: true,
+  }
+);
+
+async function handleUpdateKey() {
+  try {
+    updateKeyLoading.value = true;
+    const res = await fetchUpdateLiveRoomKey();
+    if (res.code === 200 && liveRoomInfo) {
+      // userStore.userInfo.live_rooms[0].push_obs_server =
+      //   res.data.srsPushRes.obs_server;
+      // userStore.userInfo.live_rooms[0].push_obs_stream_key =
+      //   res.data.srsPushRes.obs_stream_key;
+      // userStore.userInfo.live_rooms[0].push_rtmp_url =
+      //   res.data.srsPushRes.rtmp_url;
+      // userStore.userInfo.live_rooms[0].push_srt_url =
+      //   res.data.srsPushRes.srt_url;
+      // userStore.userInfo.live_rooms[0].push_webrtc_url =
+      //   res.data.srsPushRes.webrtc_url;
+      // userStore.userInfo.live_rooms[0].push_cdn_obs_server =
+      //   res.data.cdnPushRes.obs_server;
+      // userStore.userInfo.live_rooms[0].push_cdn_obs_stream_key =
+      //   res.data.cdnPushRes.obs_stream_key;
+      // userStore.userInfo.live_rooms[0].push_cdn_rtmp_url =
+      //   res.data.cdnPushRes.rtmp_url;
+      // userStore.userInfo.live_rooms[0].push_cdn_srt_url =
+      //   res.data.cdnPushRes.srt_url;
+      // userStore.userInfo.live_rooms[0].push_cdn_webrtc_url =
+      //   res.data.cdnPushRes.webrtc_url;
+    }
+  } catch (error) {
+    console.error(error);
+  } finally {
+    updateKeyLoading.value = false;
+  }
+}
+
+function handleCopy(url: string) {
+  copyToClipBoard(url);
+  window.$message.success('复制成功!');
+}
+
+async function handleUpdateMyLiveRoom() {
+  const res = await fetchUpdateMyLiveRoom({
+    forward_bilibili_url: liveRoomInfo.value?.forward_bilibili_url,
+    forward_douyin_url: liveRoomInfo.value?.forward_douyin_url,
+    forward_douyu_url: liveRoomInfo.value?.forward_douyu_url,
+    forward_huya_url: liveRoomInfo.value?.forward_huya_url,
+    forward_kuaishou_url: liveRoomInfo.value?.forward_kuaishou_url,
+    forward_xiaohongshu_url: liveRoomInfo.value?.forward_xiaohongshu_url,
+  });
+  if (res.code === 200) {
+    window.$message.success('修改成功!');
+  }
+}
+
+function openLiveRoom() {
+  if (!loginTip()) {
+    return;
+  }
+  const url = router.resolve({
+    name: routerName.push,
+    query: { [URL_QUERY.liveType]: LiveRoomTypeEnum.srs },
+  });
+  openToTarget(url.href);
+}
+</script>
+
+<style lang="scss" scoped>
+.wrap {
+  .title {
+    font-size: 24px;
+    color: #333;
+    margin-bottom: 20px;
+  }
+  .card {
+    padding: 20px;
+    border: 1px solid #e9eaec;
+    border-radius: 12px;
+    background-color: white;
+    background-color: white;
+    .link {
+      cursor: pointer;
+      color: $theme-color-gold;
+      text-decoration: none;
+    }
+  }
+}
+</style>

+ 293 - 0
src/views/center/liveroom/start-live/index.vue

@@ -0,0 +1,293 @@
+<template>
+  <div class="wrap">
+    <div class="title">开播设置</div>
+    <div
+      v-if="liveRoomInfo"
+      class="card-top"
+    >
+      <div class="avatar-wrap">
+        <div
+          v-if="userStore.userInfo?.avatar"
+          class="avatar"
+          :style="{
+            backgroundImage: `url(${userStore.userInfo?.avatar})`,
+          }"
+        ></div>
+        <div
+          v-else
+          class="avatar"
+        ></div>
+      </div>
+      <div class="name-bar">
+        <div class="name">{{ liveRoomInfo.name }}</div>
+      </div>
+      <div class="info-bar">
+        <div class="item id">
+          <span>
+            直播间ID:<span class="val">{{ liveRoomInfo.id }}</span>
+          </span>
+          <span
+            class="copy"
+            @click="handleCopy(liveRoomInfo.id!)"
+          >
+            复制ID
+          </span>
+        </div>
+        <div class="item url">
+          <span>
+            直播间链接:<span class="val">{{
+              getLiveRoomPageUrl(liveRoomInfo.id!)
+            }}</span>
+          </span>
+          <span
+            class="copy"
+            @click="handleCopy(getLiveRoomPageUrl(liveRoomInfo.id!))"
+          >
+            复制链接
+          </span>
+        </div>
+      </div>
+    </div>
+    <div
+      v-if="liveRoomInfo"
+      class="card"
+    >
+      <div>
+        <div class="title">直播分区:</div>
+        <div class="area-wrap">
+          {{ liveRoomInfo.areas?.map((v) => v.name).join() }}
+          <span class="save">修改分区</span>
+        </div>
+        <div class="title">房间标题:</div>
+        <div class="ipt-wrap">
+          <div class="ipt">
+            <n-input
+              v-model:value="title"
+              placeholder=""
+              maxlength="20"
+              show-count
+              clearable
+            />
+          </div>
+          <span class="save">保存</span>
+        </div>
+        <div class="title">谁可以看:</div>
+      </div>
+    </div>
+    <span
+      v-else
+      class="link"
+      @click="openLiveRoom"
+    >
+      未开通
+    </span>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { copyToClipBoard, openToTarget } from 'billd-utils';
+import { ref, toRefs, watch } from 'vue';
+
+import { fetchUpdateLiveRoomKey, fetchUpdateMyLiveRoom } from '@/api/liveRoom';
+import { URL_QUERY } from '@/constant';
+import { loginTip } from '@/hooks/use-login';
+import router, { routerName } from '@/router';
+import { useUserStore } from '@/store/user';
+import { ILiveRoom, LiveRoomTypeEnum } from '@/types/ILiveRoom';
+import { getLiveRoomPageUrl } from '@/utils';
+
+const userStore = useUserStore();
+const { userInfo } = toRefs(userStore);
+const liveRoomInfo = ref<ILiveRoom>();
+const updateKeyLoading = ref(false);
+const title = ref('');
+
+watch(
+  () => userInfo?.value,
+  (newval) => {
+    const res = newval?.live_rooms?.[0];
+    if (res) {
+      liveRoomInfo.value = res;
+      title.value = res.name || '';
+    }
+  },
+  {
+    immediate: true,
+  }
+);
+
+// @ts-ignore
+async function handleUpdateKey() {
+  try {
+    updateKeyLoading.value = true;
+    const res = await fetchUpdateLiveRoomKey();
+    if (res.code === 200 && liveRoomInfo) {
+      // userStore.userInfo.live_rooms[0].push_obs_server =
+      //   res.data.srsPushRes.obs_server;
+      // userStore.userInfo.live_rooms[0].push_obs_stream_key =
+      //   res.data.srsPushRes.obs_stream_key;
+      // userStore.userInfo.live_rooms[0].push_rtmp_url =
+      //   res.data.srsPushRes.rtmp_url;
+      // userStore.userInfo.live_rooms[0].push_srt_url =
+      //   res.data.srsPushRes.srt_url;
+      // userStore.userInfo.live_rooms[0].push_webrtc_url =
+      //   res.data.srsPushRes.webrtc_url;
+      // userStore.userInfo.live_rooms[0].push_cdn_obs_server =
+      //   res.data.cdnPushRes.obs_server;
+      // userStore.userInfo.live_rooms[0].push_cdn_obs_stream_key =
+      //   res.data.cdnPushRes.obs_stream_key;
+      // userStore.userInfo.live_rooms[0].push_cdn_rtmp_url =
+      //   res.data.cdnPushRes.rtmp_url;
+      // userStore.userInfo.live_rooms[0].push_cdn_srt_url =
+      //   res.data.cdnPushRes.srt_url;
+      // userStore.userInfo.live_rooms[0].push_cdn_webrtc_url =
+      //   res.data.cdnPushRes.webrtc_url;
+    }
+  } catch (error) {
+    console.error(error);
+  } finally {
+    updateKeyLoading.value = false;
+  }
+}
+
+function handleCopy(url: string | number) {
+  copyToClipBoard(`${url}`);
+  window.$message.success('复制成功!');
+}
+
+// @ts-ignore
+async function handleUpdateMyLiveRoom() {
+  const res = await fetchUpdateMyLiveRoom({
+    forward_bilibili_url: liveRoomInfo.value?.forward_bilibili_url,
+    forward_douyin_url: liveRoomInfo.value?.forward_douyin_url,
+    forward_douyu_url: liveRoomInfo.value?.forward_douyu_url,
+    forward_huya_url: liveRoomInfo.value?.forward_huya_url,
+    forward_kuaishou_url: liveRoomInfo.value?.forward_kuaishou_url,
+    forward_xiaohongshu_url: liveRoomInfo.value?.forward_xiaohongshu_url,
+  });
+  if (res.code === 200) {
+    window.$message.success('修改成功!');
+  }
+}
+
+function openLiveRoom() {
+  if (!loginTip()) {
+    return;
+  }
+  const url = router.resolve({
+    name: routerName.push,
+    query: { [URL_QUERY.liveType]: LiveRoomTypeEnum.srs },
+  });
+  openToTarget(url.href);
+}
+</script>
+
+<style lang="scss" scoped>
+.wrap {
+  .title {
+    margin-bottom: 20px;
+    color: #333;
+    font-size: 24px;
+  }
+  .card-top {
+    position: relative;
+    margin-top: 60px;
+    margin-bottom: 20px;
+    padding: 20px 30px;
+    width: 50%;
+    border: 1px solid #e9eaec;
+    border-radius: 12px;
+    background-color: white;
+    background-color: white;
+
+    .avatar-wrap {
+      position: absolute;
+      top: -40px;
+      left: 30px;
+      width: 90px;
+      height: 90px;
+      border: 2px solid #ececec;
+      border-radius: 50%;
+      .avatar {
+        box-sizing: border-box;
+        width: 100%;
+        height: 100%;
+        border: 2px solid white;
+        border-radius: 50%;
+        background-color: white;
+        background-image: url('@/assets/img/default-avatar.png');
+
+        @extend %containBg;
+      }
+    }
+    .name-bar {
+      box-sizing: border-box;
+      padding-top: 10px;
+      padding-bottom: 25px;
+      padding-left: 105px;
+      width: 100%;
+      border-bottom: 1px solid #e3e8ec;
+    }
+    .info-bar {
+      box-sizing: border-box;
+      padding-top: 30px;
+      width: 100%;
+      font-size: 12px;
+      .item {
+        margin-bottom: 20px;
+        .val {
+          color: $theme-color-gold;
+          font-size: 16px;
+        }
+        .copy {
+          margin-left: 30px;
+          color: $theme-color-gold;
+          cursor: pointer;
+        }
+      }
+    }
+  }
+  .card {
+    margin-bottom: 20px;
+    padding: 20px;
+    border: 1px solid #e9eaec;
+    border-radius: 12px;
+    background-color: white;
+    background-color: white;
+    .link {
+      color: $theme-color-gold;
+      cursor: pointer;
+    }
+    .title {
+      font-size: 20px;
+    }
+    .ipt-wrap {
+      display: flex;
+      align-items: center;
+      margin-bottom: 20px;
+
+      .ipt {
+        width: 300px;
+        margin-right: 10px;
+      }
+      .save {
+        color: #999;
+        font-size: 12px;
+        cursor: pointer;
+      }
+    }
+    .area-wrap {
+      display: flex;
+      align-items: center;
+      margin-bottom: 20px;
+
+      .save {
+        color: $theme-color-gold;
+        font-size: 12px;
+        cursor: pointer;
+        margin-left: 20px;
+      }
+    }
+  }
+}
+</style>

+ 25 - 0
src/views/center/user/income/index.vue

@@ -0,0 +1,25 @@
+<template>
+  <div class="wrap">
+    <div class="title">个人收益</div>
+    <div class="card"></div>
+  </div>
+</template>
+
+<script lang="ts" setup></script>
+
+<style lang="scss" scoped>
+.wrap {
+  .title {
+    font-size: 24px;
+    color: #333;
+    margin-bottom: 20px;
+  }
+  .card {
+    border: 1px solid #e9eaec;
+    border-radius: 12px;
+    background-color: white;
+    background-color: white;
+    height: 200px;
+  }
+}
+</style>

+ 7 - 0
src/views/center/user/index.vue

@@ -0,0 +1,7 @@
+<template>
+  <router-view></router-view>
+</template>
+
+<script lang="ts" setup></script>
+
+<style lang="scss" scoped></style>

+ 25 - 0
src/views/center/user/info/index.vue

@@ -0,0 +1,25 @@
+<template>
+  <div class="wrap">
+    <div class="title">个人信息</div>
+    <div class="card"></div>
+  </div>
+</template>
+
+<script lang="ts" setup></script>
+
+<style lang="scss" scoped>
+.wrap {
+  .title {
+    font-size: 24px;
+    color: #333;
+    margin-bottom: 20px;
+  }
+  .card {
+    border: 1px solid #e9eaec;
+    border-radius: 12px;
+    background-color: white;
+    background-color: white;
+    height: 200px;
+  }
+}
+</style>

+ 142 - 0
src/views/doc/ad/index.vue

@@ -0,0 +1,142 @@
+<template>
+  <div class="ad-wrap">
+    <h1 class="title">广告位招租</h1>
+    <h3 class="title">
+      {{
+        PROJECT_NAME
+      }}服务器:阿里云轻量服务器:2核cpu、2GB内存、30M带宽(香港)
+    </h3>
+
+    <div class="ad-list">
+      <div
+        v-for="(item, index) in list"
+        :key="index"
+        class="item"
+        @click="openToTarget(item.url)"
+      >
+        <div>
+          <img
+            v-lazy="item.cover"
+            class="cover"
+            alt=""
+          />
+        </div>
+        <div>
+          <div>{{ item.name }}</div>
+          <div
+            v-for="(iten, indey) in item.list"
+            :key="indey"
+            class="hot"
+          >
+            {{ iten }}
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { openToTarget } from 'billd-utils';
+import { ref } from 'vue';
+
+import { PROJECT_NAME } from '@/constant';
+
+const list = ref([
+  {
+    cover: '',
+    name: '【腾讯云】个人开发者 买赠福利专区 买即送 个人开发者专享免费服务器3个月!',
+    list: [
+      '【腾讯云】:1年轻量服务器 2核cpu、2G内存、4M带宽、50G系统盘(上海/广州/北京),132元!',
+      '【腾讯云】:1年轻量服务器 2核cpu、4G内存、5M带宽、60G系统盘(上海/广州/北京),198元!',
+    ],
+    url: 'https://cloud.tencent.com/act/cps/redirect?redirect=35916&cps_key=ebe55ad4d940d688bcde548b101dff5f',
+  },
+  {
+    cover: '',
+    name: '【腾讯云】爆品抢先购,预热专属折上折券限时领!',
+    list: [
+      '【腾讯云】:1年轻量服务器 2核cpu、2G内存、3M带宽、40G系统盘(上海/广州/北京),95元!',
+      '【腾讯云】:1年轻量服务器 2核cpu、4G内存、5M带宽、60G系统盘(上海/广州/北京),168元!',
+      '【腾讯云】:3年轻量服务器 2核cpu、2G内存、4M带宽、50G系统盘(上海/广州/北京),396元!',
+      '【腾讯云】:3年轻量服务器 2核cpu、4G内存、5M带宽、60G系统盘(上海/广州/北京),628元!',
+    ],
+    url: 'https://cloud.tencent.com/act/cps/redirect?redirect=35864&cps_key=ebe55ad4d940d688bcde548b101dff5f',
+  },
+  {
+    cover: '',
+    name: '【腾讯云】推广者专属福利,新客户无门槛领取总价值高达2860元代金券,每种代金券限量500张,先到先得。',
+    list: [
+      '【腾讯云】满200减100,仅用于非促销新购云服务器、MySQL数据库',
+      '【腾讯云】满500减250,仅用于非促销新购云服务器、MySQL数据库',
+      '【腾讯云】满1000减500,仅用于非促销新购云服务器、MySQL数据库',
+      '【腾讯云】满2000减1000,仅用于非促销新购云服务器、MySQL数据库',
+    ],
+    url: 'https://cloud.tencent.com/act/cps/redirect?redirect=35834&cps_key=ebe55ad4d940d688bcde548b101dff5f',
+  },
+  {
+    cover: '',
+    name: '云小站特惠超底价',
+    url: 'https://www.aliyun.com/minisite/goods?userCode=41m2k6bt',
+  },
+  {
+    cover: '',
+    name: '云服务器t6 2核2G低至10.14元/月',
+    url: 'https://www.aliyun.com/daily-act/ecs/activity_share?userCode=41m2k6bt',
+  },
+  {
+    cover: '',
+    name: '新人免费 CDN(全球2000+节点无盲区覆盖,注册即可免费使用)',
+    url: 'https://s.qiniu.com/Q7zqeq',
+  },
+  {
+    cover: '',
+    name: '对象存储新人好礼(10年专注云存储,注册即可免费使用)',
+    url: 'https://s.qiniu.com/iQR7Jz',
+  },
+  {
+    cover: '',
+    name: '新人免费试用(多款云产品长期免费使用,注册即享超值赠送)',
+    url: 'https://s.qiniu.com/JnIbyq',
+  },
+  {
+    cover: '',
+    name: '新人免费云主机(注册免费领取 4核8G 云服务器,享免费数据迁移服务)',
+    url: 'https://s.qiniu.com/B3i63y',
+  },
+]);
+</script>
+
+<style lang="scss" scoped>
+.ad-wrap {
+  .title {
+    text-align: center;
+  }
+  .ad-list {
+    padding: 20px;
+
+    .item {
+      display: flex;
+      align-items: center;
+      margin-right: 20px;
+      padding: 10px;
+      border-radius: 10px;
+      box-shadow: 0 4px 30px 0 rgba(238, 242, 245, 0.8);
+      cursor: pointer;
+      transition: box-shadow 0.2s linear;
+      margin-top: 10px;
+      margin-bottom: 10px;
+      &:hover {
+        box-shadow: 4px 4px 20px 0 rgba(205, 216, 228, 0.6);
+      }
+      .cover {
+        width: 300px;
+        margin-right: 10px;
+      }
+      .hot {
+        color: red;
+      }
+    }
+  }
+}
+</style>

+ 31 - 0
src/views/doc/api/index.vue

@@ -0,0 +1,31 @@
+<template>
+  <div class="api-doc-wrap">
+    <p>
+      接口文档:<a
+        :href="COMMON_URL.apifox"
+        class="link"
+        @click.prevent="openToTarget(COMMON_URL.apifox)"
+        >apifox</a
+      >
+    </p>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { openToTarget } from 'billd-utils';
+
+import { COMMON_URL } from '@/constant';
+</script>
+
+<style lang="scss" scoped>
+.api-doc-wrap {
+  padding-top: calc($header-height + 10px);
+  padding-left: 20px;
+  padding-right: 20px;
+  .link {
+    color: $theme-color-gold;
+    text-decoration: none;
+    cursor: pointer;
+  }
+}
+</style>

+ 188 - 0
src/views/doc/faq/index.vue

@@ -0,0 +1,188 @@
+<template>
+  <div class="faq-wrap">
+    <div class="content">
+      <h1 class="title">常见问题</h1>
+      <div class="hr"></div>
+      <div class="list">
+        <div class="item">
+          <h2>如何本地运行{{ PROJECT_NAME }}?</h2>
+          <p>
+            一: 仔细看完
+            <span
+              class="link"
+              @click="
+                openToTarget('https://github.com/billd-project/live#readme')
+              "
+            >
+              {{ PROJECT_NAME }}的README
+            </span>
+          </p>
+          <p>
+            二:仔细看完
+            <span
+              class="link"
+              @click="openToTarget(COMMON_URL.bilibiliCollectiondetail)"
+            >
+              b站教程(封面是"从零搭建迷你b站直播间")
+            </span>
+          </p>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>{{ PROJECT_NAME }}是什么?</h2>
+          <p>
+            {{ PROJECT_NAME }}
+            是一个web端的直播平台,目前支持使用WebRTC、SRS、腾讯云云直播进行直播:
+          </p>
+          <p>- 原生WebRTC一对多直播</p>
+          <p>- 原生WebRTC多对多直播</p>
+          <p>- SRS WebRTC一对多直播</p>
+          <p>- 打PK直播</p>
+          <p>- CDN直播</p>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>谁在维护{{ PROJECT_NAME }}?</h2>
+          <p>
+            {{ PROJECT_NAME }}是由
+            <a
+              :href="AUTHOR_INFO.github"
+              target="_blank"
+              class="link"
+            >
+              galaxy-s10
+            </a>
+            在 2023年3月作为其个人项目创建的,目前只有作者一人维护。
+          </p>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>{{ PROJECT_NAME }}使用了什么技术栈?</h2>
+          <p>
+            {{
+              PROJECT_NAME
+            }}是一个偏前端的全栈项目,几乎所有技术栈都是前端相关。
+          </p>
+          <p>
+            前端相关:Typescript、Vue3、<a
+              target="_blank"
+              class="link"
+              href="https://developer.mozilla.org/zh-CN/docs/Web/API/WebRTC_API"
+            >
+              WebRTC </a
+            >、Web Audio、Web Worker、Canvas
+          </p>
+          <p>后端相关:Node、Koa2、MySQL、Redis、Socket.io、 Typescript</p>
+          <p>
+            音视频相关:
+            <a
+              target="_blank"
+              class="link"
+              href="https://ossrs.net"
+            >
+              SRS
+            </a>
+            <span>、</span>
+            <a
+              target="_blank"
+              class="link"
+              href="https://ffmpeg.org"
+            >
+              FFmpeg
+            </a>
+          </p>
+          <p>部署相关:Docker、Jenkins</p>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>obs/ffmpeg推流到{{ PROJECT_NAME }}失败</h2>
+          <p>
+            服务器性能有限,限制了推流码率为:<b>3000kbps</b>,超过则会导致推流失败!
+          </p>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>如何参与贡献?</h2>
+          <p>非常欢迎!欢迎提Issue 或 PR!</p>
+        </div>
+      </div>
+    </div>
+    <div class="aside">
+      <div class="title">本页目录</div>
+      <div class="item">如何本地运行{{ PROJECT_NAME }}?</div>
+      <div class="item">{{ PROJECT_NAME }}是什么?</div>
+      <div class="item">谁在维护{{ PROJECT_NAME }}?</div>
+      <div class="item">{{ PROJECT_NAME }}使用了什么技术栈?</div>
+      <div class="item">obs/ffmpeg推流到{{ PROJECT_NAME }}失败</div>
+      <div class="item">如何参与贡献?</div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { openToTarget } from 'billd-utils';
+
+import { AUTHOR_INFO, COMMON_URL, PROJECT_NAME } from '@/constant';
+</script>
+
+<style lang="scss" scoped>
+.faq-wrap {
+  display: flex;
+  box-sizing: border-box;
+  margin: 0 auto;
+  padding-top: calc($header-height + 10px);
+  width: 960px;
+  color: rgb(33, 53, 71);
+
+  .content {
+    flex: 1;
+    .title {
+      margin: 0;
+      font-weight: 500;
+      font-size: 40px;
+      margin-bottom: 60px;
+    }
+    .hr {
+      margin: 60px 0 20px 0;
+      width: 100%;
+      height: 1px;
+      background-color: #e7e7e7;
+    }
+
+    .link {
+      color: $theme-color-gold;
+      text-decoration: none;
+      font-weight: 500;
+      cursor: pointer;
+    }
+    .list {
+      h2 {
+        font-weight: 600;
+      }
+      .item {
+        font-size: 16px;
+      }
+    }
+  }
+  .aside {
+    padding-left: 90px;
+    width: 150px;
+
+    .title {
+      color: rgb(33, 53, 71);
+      font-weight: 700;
+      font-size: 12px;
+      margin-bottom: 8px;
+    }
+    .item {
+      margin-bottom: 8px;
+      color: rgba(60, 60, 60, 0.7);
+      font-size: 13px;
+      cursor: pointer;
+      &:hover {
+        color: #213547;
+      }
+    }
+  }
+}
+</style>

+ 20 - 0
src/views/doc/guide/index.vue

@@ -0,0 +1,20 @@
+<template>
+  <div class="guide-wrap">
+    <p>敬请期待!</p>
+  </div>
+</template>
+
+<script lang="ts" setup></script>
+
+<style lang="scss" scoped>
+.guide-wrap {
+  padding-top: calc($header-height + 10px);
+  padding-left: 20px;
+  padding-right: 20px;
+  .link {
+    color: $theme-color-gold;
+    text-decoration: none;
+    cursor: pointer;
+  }
+}
+</style>

+ 134 - 0
src/views/doc/pushStreamDifferent/index.vue

@@ -0,0 +1,134 @@
+<template>
+  <div class="release-wrap">
+    <div class="content">
+      <h1 class="title">不同开播的区别</h1>
+      <div class="hr"></div>
+      <div class="list">
+        <div class="item">
+          <h2>SRS直播</h2>
+          <p>
+            推流到服务器,由服务器的SRS进行转发(RTMP、FLV、HLS、WebRTC等)。
+          </p>
+          <p>优点:不同客户端都能拉流。</p>
+          <p>缺点:对服务器带宽要求高。</p>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>WebRTC直播</h2>
+          <p>原生WebRTC一对多。</p>
+          <p>优点:延迟低。</p>
+          <p>缺点:对客户端上传带宽要求高。</p>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>WebRTC会议</h2>
+          <p>原生WebRTC多对多。</p>
+          <p>优点:延迟低。</p>
+          <p>缺点:对客户端上传带宽要求高。</p>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>Msr直播</h2>
+          <p>
+            类似bilibili的web直播间实现,浏览器录制直播画面,并上传到服务器进行推流。
+          </p>
+          <p>优点:兼容性对比webRTC好。</p>
+          <p>
+            缺点:延迟高,且对网络要求严格,并且也需要后端和音视频相关部门的支持。
+          </p>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>打PK直播</h2>
+          <p>
+            不同主播之间进行直播互动,然后由任意一个主播将所有画面进行混流,推送到服务器的SRS进行转发,让观众能看到这个直播互动。
+          </p>
+          <p>此方式是webRTC直播和SRS直播的混合,优缺点也继承自它们。</p>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>腾讯云直播</h2>
+          <p>
+            推流到腾讯云云直播,由腾讯云进行转发(RTMP、FLV、HLS、WebRTC等)。
+          </p>
+          <p>优点:腾讯云云直播自带CDN。</p>
+          <p>缺点:收费。</p>
+        </div>
+        <div class="item">
+          <h2>腾讯云打PK</h2>
+          <p>在打PK直播的基础上,将SRS替换成腾讯云云直播。</p>
+          <p>优点:腾讯云云直播自带CDN。</p>
+          <p>缺点:收费。</p>
+        </div>
+      </div>
+    </div>
+    <div class="aside">
+      <div class="title">本页目录</div>
+      <div class="item">SRS直播</div>
+      <div class="item">WebRTC直播</div>
+      <div class="item">WebRTC会议</div>
+      <div class="item">Msr直播</div>
+      <div class="item">打PK直播</div>
+      <div class="item">腾讯云直播</div>
+      <div class="item">腾讯云打PK</div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup></script>
+
+<style lang="scss" scoped>
+.release-wrap {
+  display: flex;
+  box-sizing: border-box;
+  margin: 0 auto;
+  padding-top: 50px;
+  width: 960px;
+  color: rgb(33, 53, 71);
+
+  .content {
+    flex: 1;
+    .title {
+      margin: 0;
+      font-weight: 500;
+      font-size: 40px;
+      margin-bottom: 20px;
+    }
+    .hr {
+      margin: 60px 0 20px 0;
+      width: 100%;
+      height: 1px;
+      background-color: #e7e7e7;
+    }
+    .list {
+      h2 {
+        font-weight: 600;
+      }
+      .item {
+        position: relative;
+        font-size: 16px;
+      }
+    }
+  }
+
+  .aside {
+    padding-left: 90px;
+    width: 150px;
+    .title {
+      color: rgb(33, 53, 71);
+      font-weight: 700;
+      font-size: 12px;
+      margin-bottom: 8px;
+    }
+    .item {
+      margin-bottom: 8px;
+      color: rgba(60, 60, 60, 0.7);
+      font-size: 13px;
+      cursor: pointer;
+      &:hover {
+        color: #213547;
+      }
+    }
+  }
+}
+</style>

+ 174 - 0
src/views/download/desk/index.vue

@@ -0,0 +1,174 @@
+<template>
+  <div class="download-wrap">
+    <div class="content">
+      <h1 class="title">远程桌面客户端下载</h1>
+      <div class="hr"></div>
+      <div class="list">
+        <div class="item">
+          <h2>Electron</h2>
+          <span>
+            代码仓库:<span
+              class="link"
+              @click="openToTarget(COMMON_URL.download.desk.electron.github)"
+            >
+              {{ COMMON_URL.download.desk.electron.github }}
+            </span>
+          </span>
+          <h3>Windows</h3>
+          <div>
+            <span
+              >最新版本:<span
+                class="link"
+                @click="openToTarget(COMMON_URL.download.desk.electron.windows)"
+                >下载</span
+              ></span
+            >
+          </div>
+          <h3>macOS</h3>
+          <div>
+            <span
+              >最新版本:<span
+                class="link"
+                @click="openToTarget(COMMON_URL.download.desk.electron.macOS)"
+                >下载</span
+              ></span
+            >
+          </div>
+          <h3>Linux</h3>
+          <div>
+            <span>最新版本:<span>自行构建</span></span>
+          </div>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>Flutter</h2>
+          <span>
+            代码仓库:<span
+              class="link"
+              @click="openToTarget(COMMON_URL.download.desk.flutter.github)"
+            >
+              {{ COMMON_URL.download.desk.flutter.github }}
+            </span>
+          </span>
+          <h3>安卓</h3>
+          <div>
+            <span>最新版本:<span>todo</span></span>
+          </div>
+          <h3>苹果</h3>
+          <div>
+            <span>最新版本:<span>todo</span></span>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="aside">
+      <div class="title">本页目录</div>
+      <div class="item h1">Electron</div>
+      <div class="item h2">Windows</div>
+      <div class="item h2">macOS</div>
+      <div class="item h2">Linux</div>
+      <div class="item h1">Flutter</div>
+      <div class="item h2">安卓</div>
+      <div class="item h2">苹果</div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { openToTarget } from 'billd-utils';
+import { onMounted, ref } from 'vue';
+
+import { fetchServerInfo } from '@/api/other';
+import { COMMON_URL } from '@/constant';
+import { IServerInfo } from '@/interface';
+
+const serverInfo = ref<IServerInfo>();
+const loading = ref(false);
+
+async function handleFetchServerInfo() {
+  try {
+    loading.value = true;
+    const res = await fetchServerInfo();
+    if (res.code === 200) {
+      serverInfo.value = res.data;
+    }
+  } catch (error) {
+    console.log(error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+onMounted(() => {
+  handleFetchServerInfo();
+});
+</script>
+
+<style lang="scss" scoped>
+.download-wrap {
+  display: flex;
+  box-sizing: border-box;
+  margin: 0 auto;
+  padding-top: calc($header-height + 10px);
+  padding-left: 20px;
+  padding-right: 20px;
+  padding-bottom: 10px;
+  width: 960px;
+  color: rgb(33, 53, 71);
+  .link {
+    color: $theme-color-gold;
+    cursor: pointer;
+  }
+
+  .content {
+    flex: 1;
+    .title {
+      margin: 0;
+      margin-bottom: 20px;
+      font-weight: 500;
+      font-size: 40px;
+    }
+    .hr {
+      margin: 60px 0 20px 0;
+      width: 100%;
+      height: 1px;
+      background-color: #e7e7e7;
+    }
+    .list {
+      h2 {
+        font-weight: 600;
+      }
+      .item {
+        position: relative;
+        font-size: 16px;
+      }
+    }
+  }
+
+  .aside {
+    padding-left: 90px;
+    width: 150px;
+
+    .title {
+      margin-bottom: 8px;
+      color: rgb(33, 53, 71);
+      font-weight: 700;
+      font-size: 12px;
+    }
+    .item {
+      margin-bottom: 8px;
+      color: rgba(60, 60, 60, 0.7);
+      font-size: 13px;
+      cursor: pointer;
+      &:hover {
+        color: #213547;
+      }
+      &.h1 {
+      }
+      &.h2 {
+        margin-left: 10px;
+      }
+    }
+  }
+}
+</style>

+ 210 - 0
src/views/download/live/index.vue

@@ -0,0 +1,210 @@
+<template>
+  <div class="download-wrap">
+    <div class="content">
+      <h1 class="title">直播客户端下载</h1>
+      <div class="hr"></div>
+      <div class="list">
+        <div class="item">
+          <h2>Flutter</h2>
+          <span>
+            代码仓库:<span
+              class="link"
+              @click="openToTarget(COMMON_URL.download.live.flutter.github)"
+            >
+              {{ COMMON_URL.download.live.flutter.github }}
+            </span>
+          </span>
+          <h3>安卓</h3>
+          <div>
+            <span>
+              最新版本:<span
+                class="link"
+                @click="openToTarget(COMMON_URL.download.live.flutter.android)"
+              >
+                v0.0.4
+              </span>
+            </span>
+          </div>
+          <h3>苹果</h3>
+          <div>
+            <span>最新版本:<span>自行构建</span></span>
+          </div>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>React-Native</h2>
+          <span>
+            代码仓库:<span
+              class="link"
+              @click="openToTarget(COMMON_URL.download.live.reactNative.github)"
+            >
+              {{ COMMON_URL.download.live.reactNative.github }}
+            </span>
+          </span>
+          <h3>安卓</h3>
+          <div>
+            <span>最新版本:<span>进度:30%</span></span>
+          </div>
+          <h3>苹果</h3>
+          <div>
+            <span>最新版本:<span>进度:30%</span></span>
+          </div>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>Kotilin</h2>
+          <span>
+            代码仓库:<span
+              class="link"
+              @click="openToTarget(COMMON_URL.download.live.kotlin.github)"
+            >
+              {{ COMMON_URL.download.live.kotlin.github }}
+            </span>
+          </span>
+          <h3>安卓</h3>
+          <div>
+            <span>最新版本:<span>进度:1%</span></span>
+          </div>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>Electron</h2>
+          <span>
+            代码仓库:<span
+              class="link"
+              @click="openToTarget(COMMON_URL.download.live.electron.github)"
+            >
+              {{ COMMON_URL.download.live.electron.github }}
+            </span>
+          </span>
+          <h3>Windows</h3>
+          <div>
+            <span>最新版本:<span>todo</span></span>
+          </div>
+          <h3>macOS</h3>
+          <div>
+            <span>最新版本:<span>todo</span></span>
+          </div>
+          <h3>Linux</h3>
+          <div>
+            <span>最新版本:<span>todo</span></span>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="aside">
+      <div class="title">本页目录</div>
+      <div class="item h1">Flutter</div>
+      <div class="item h2">安卓</div>
+      <div class="item h2">苹果</div>
+      <div class="item h1">React-Native</div>
+      <div class="item h2">安卓</div>
+      <div class="item h2">苹果</div>
+      <div class="item h1">Kotlin</div>
+      <div class="item h2">安卓</div>
+      <div class="item h1">Electron</div>
+      <div class="item h2">Windows</div>
+      <div class="item h2">macOS</div>
+      <div class="item h2">Linux</div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { openToTarget } from 'billd-utils';
+import { onMounted, ref } from 'vue';
+
+import { fetchServerInfo } from '@/api/other';
+import { COMMON_URL } from '@/constant';
+import { IServerInfo } from '@/interface';
+
+const serverInfo = ref<IServerInfo>();
+const loading = ref(false);
+
+async function handleFetchServerInfo() {
+  try {
+    loading.value = true;
+    const res = await fetchServerInfo();
+    if (res.code === 200) {
+      serverInfo.value = res.data;
+    }
+  } catch (error) {
+    console.log(error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+onMounted(() => {
+  handleFetchServerInfo();
+});
+</script>
+
+<style lang="scss" scoped>
+.download-wrap {
+  display: flex;
+  box-sizing: border-box;
+  margin: 0 auto;
+  padding-top: calc($header-height + 10px);
+  padding-left: 20px;
+  padding-right: 20px;
+  padding-bottom: 10px;
+  width: 960px;
+  color: rgb(33, 53, 71);
+  .link {
+    color: $theme-color-gold;
+    cursor: pointer;
+  }
+
+  .content {
+    flex: 1;
+    .title {
+      margin: 0;
+      margin-bottom: 20px;
+      font-weight: 500;
+      font-size: 40px;
+    }
+    .hr {
+      margin: 60px 0 20px 0;
+      width: 100%;
+      height: 1px;
+      background-color: #e7e7e7;
+    }
+    .list {
+      h2 {
+        font-weight: 600;
+      }
+      .item {
+        position: relative;
+        font-size: 16px;
+      }
+    }
+  }
+
+  .aside {
+    padding-left: 90px;
+    width: 150px;
+
+    .title {
+      margin-bottom: 8px;
+      color: rgb(33, 53, 71);
+      font-weight: 700;
+      font-size: 12px;
+    }
+    .item {
+      margin-bottom: 8px;
+      color: rgba(60, 60, 60, 0.7);
+      font-size: 13px;
+      cursor: pointer;
+      &:hover {
+        color: #213547;
+      }
+      &.h1 {
+      }
+      &.h2 {
+        margin-left: 10px;
+      }
+    }
+  }
+}
+</style>

+ 826 - 0
src/views/home/index.vue

@@ -0,0 +1,826 @@
+<template>
+  <div
+    ref="docRef"
+    class="home-wrap"
+  >
+    <div class="play-container">
+      <div
+        v-if="configBg && configBg !== ''"
+        class="bg-img"
+        :style="{
+          backgroundImage: `url(${configBg})`,
+        }"
+      ></div>
+      <video
+        v-if="configVideo && configVideo !== ''"
+        class="bg-video"
+        :src="configVideo"
+        muted
+        autoplay
+        loop
+      ></video>
+      <div class="container">
+        <div
+          ref="videoWrapTmpRef"
+          v-loading="videoLoading"
+          class="left"
+          @click="showJoinBtn = !showJoinBtn"
+        >
+          <div
+            v-if="currentLive?.live_room?.cdn === SwitchEnum.yes"
+            class="cdn-ico"
+          >
+            <div class="txt">CDN</div>
+          </div>
+          <div class="logo-watermark">Billd直播</div>
+          <div
+            class="cover"
+            :style="{
+              backgroundImage: `url(${
+                currentLive?.live_room?.cover_img || currentLive?.user?.avatar
+              })`,
+            }"
+          ></div>
+          <div
+            v-if="currentLive?.live_room"
+            ref="remoteVideoRef"
+          ></div>
+          <template v-if="currentLive?.live_room">
+            <div class="video-controls">
+              <VideoControls
+                :resolution="videoResolution"
+                :control="{
+                  line: true,
+                }"
+                :liveRoom="currentLive?.live_room"
+                :liveLineList="[LiveLineEnum.flv, LiveLineEnum.hls]"
+                @refresh="handleRefresh"
+              ></VideoControls>
+            </div>
+
+            <div
+              class="join-btn"
+              :class="{
+                show: showJoinBtn,
+              }"
+            >
+              <div
+                class="btn"
+                @click="joinRoom(currentLive?.live_room, 'false')"
+              >
+                进入直播
+              </div>
+            </div>
+          </template>
+        </div>
+        <div class="right">
+          <div
+            v-if="topLiveRoomList.length"
+            class="list"
+          >
+            <div
+              v-for="(item, index) in topLiveRoomList"
+              :key="index"
+              :class="{
+                item: 1,
+                active: item.live_room?.id === currentLive?.live_room?.id,
+              }"
+              :style="{
+                backgroundImage: `url(${
+                  item.live_room?.cover_img || item.user?.avatar
+                })`,
+              }"
+              @click="changeLive(item)"
+            >
+              <div class="hidden">
+                <div
+                  v-if="item?.live_room?.cdn === SwitchEnum.yes"
+                  class="cdn-ico"
+                >
+                  <div class="txt">CDN</div>
+                </div>
+              </div>
+              <div
+                class="border"
+                :style="{
+                  opacity:
+                    item.live_room?.id === currentLive?.live_room?.id ? 1 : 0,
+                }"
+              ></div>
+              <div
+                v-if="item.live_room?.id === currentLive?.live_room?.id"
+                class="triangle"
+              ></div>
+              <div class="txt">{{ item.live_room?.name }}</div>
+            </div>
+          </div>
+          <div
+            v-else
+            class="none"
+          >
+            {{ t('home.noliveRoomTip') }}
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="area-container">
+      <div class="area-item">
+        <div class="title">{{ t('home.recommendLive') }}</div>
+        <div class="live-room-list">
+          <div
+            v-for="(item, indey) in otherLiveRoomList"
+            :key="indey"
+            class="live-room"
+            @click="joinRoom(item.live_room, 'false')"
+          >
+            <div
+              v-lazy:background-image="
+                item?.live_room?.cover_img || item?.user?.avatar
+              "
+              class="cover"
+            >
+              <div
+                v-if="item?.live_room?.cdn === SwitchEnum.yes"
+                class="cdn-ico"
+              >
+                <div class="txt">CDN</div>
+              </div>
+              <div class="txt">{{ item?.live_room?.users?.[0].username }}</div>
+            </div>
+            <div class="desc">{{ item?.live_room?.name }}</div>
+          </div>
+          <div
+            v-if="!otherLiveRoomList.length"
+            class="null"
+          >
+            {{ t('common.nonedata') }}
+          </div>
+        </div>
+      </div>
+
+      <div class="area-item">
+        <div class="title">{{ t('home.bilibiliLive') }}</div>
+        <div class="live-room-list">
+          <div
+            v-for="(iten, indey) in bilibiliLiveRoomList"
+            :key="indey"
+            class="live-room"
+            @click="joinRoom(iten.live_room, 'true')"
+          >
+            <div
+              v-lazy:background-image="iten?.live_room?.cover_img"
+              class="cover"
+            >
+              <div
+                v-if="iten?.live_room?.cdn === SwitchEnum.yes"
+                class="cdn-ico"
+              >
+                <div class="txt">CDN</div>
+              </div>
+              <div class="txt">{{ iten?.live_room?.users?.[0].username }}</div>
+            </div>
+            <div class="desc">{{ iten?.live_room?.name }}</div>
+          </div>
+          <div
+            v-if="bilibiliLoading"
+            class="null"
+          >
+            {{ t('common.loading') }}
+          </div>
+          <div
+            v-else-if="!bilibiliLiveRoomList.length"
+            class="null"
+          >
+            {{ t('common.nonedata') }}
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- <div
+      style="position: fixed; bottom: 200px; right: 0; background-color: red"
+    >
+      {{ isBottom }}
+    </div>
+     -->
+    <div ref="loadMoreRef"></div>
+    <div class="foot">*{{ t('home.copyrightTip') }}~</div>
+  </div>
+  <div
+    v-if="appStore.useGoogleAd"
+    class="ad-wrap-a"
+  >
+    <!-- live-首页广告位1 -->
+    <ins
+      class="adsbygoogle"
+      style="display: block"
+      data-ad-client="ca-pub-6064454674933772"
+      data-ad-slot="3325489849"
+      data-ad-format="auto"
+      data-full-width-responsive="true"
+    ></ins>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { openToTarget } from 'billd-utils';
+import { onMounted, reactive, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRouter } from 'vue-router';
+
+import { fetchLiveBilibiliGetUserRecommend } from '@/api/bilibili';
+import { fetchLiveList } from '@/api/live';
+import { sliderList, URL_QUERY } from '@/constant';
+import { usePull } from '@/hooks/use-pull';
+import { ILive, LiveLineEnum, SwitchEnum } from '@/interface';
+import { routerName } from '@/router';
+import { useAppStore } from '@/store/app';
+
+const router = useRouter();
+const appStore = useAppStore();
+const canvasRef = ref<Element>();
+const bilibiliLoading = ref(false);
+const showJoinBtn = ref(false);
+const topNums = ref(6);
+const configBg = ref('');
+const configVideo = ref();
+// const configVideo = ref(
+//   'https://www.xdyun.com/resldmnqcom/ldq_website/all_ldy/cloudphone_xdyun_ldy_mobile/mobile/assets/xd-video-6c9bcd.mp4'
+// );
+const currentLive = ref<ILive>();
+const topLiveRoomList = ref<ILive[]>([]);
+const otherLiveRoomList = ref<ILive[]>([]);
+const bilibiliLiveRoomList = ref<ILive[]>([]);
+const interactionList = ref<any[]>([]);
+const videoWrapTmpRef = ref<HTMLDivElement>();
+const remoteVideoRef = ref<HTMLDivElement>();
+const docRef = ref<HTMLElement | null>();
+const loadMoreRef = ref<HTMLElement | null>();
+
+const pageParams = reactive({ page: 0, page_size: 30, platform: 'web' });
+const { t } = useI18n();
+const {
+  videoWrapRef,
+  videoLoading,
+  videoResolution,
+  handleStopDrawing,
+  handlePlay,
+  stopPlay,
+} = usePull();
+const isBottom = ref(false);
+const rootMargin = {
+  bottom: 600,
+  top: 0,
+  left: 0,
+  right: 0,
+};
+
+onMounted(() => {
+  const intersectionObserver = new IntersectionObserver(
+    (entries) => {
+      entries.forEach((item) => {
+        if (item.isIntersecting) {
+          isBottom.value = true;
+        } else {
+          isBottom.value = false;
+        }
+      });
+    },
+    {
+      // root: '',
+      rootMargin: `${rootMargin.top}px ${rootMargin.right}px ${rootMargin.bottom}px ${rootMargin.left}px`,
+    }
+  );
+  intersectionObserver.observe(loadMoreRef.value!);
+  handleSlideList();
+  getLiveRoomList();
+  videoWrapRef.value = videoWrapTmpRef.value;
+});
+
+watch(
+  () => isBottom.value,
+  async (newval) => {
+    if (newval) {
+      const arr = await handleBilibilData();
+      bilibiliLiveRoomList.value.push(...arr);
+    }
+  },
+  {
+    immediate: true,
+  }
+);
+
+async function handleBilibilData() {
+  if (bilibiliLoading.value) return [];
+  bilibiliLoading.value = true;
+  let arr: any = [];
+  try {
+    pageParams.page += 1;
+    const res = await fetchLiveBilibiliGetUserRecommend(pageParams);
+    // const list = res.data?.list;
+    const list = res?.data?.data?.list;
+    if (list) {
+      arr = list.map((item) => {
+        return {
+          id: item.roomid,
+          user: { username: item.uname },
+          live_room: {
+            id: item.roomid,
+            name: item.title,
+            cover_img: item.cover,
+          },
+        };
+      });
+    }
+  } catch (error) {
+    pageParams.page -= 1;
+    console.log(error);
+  }
+  bilibiliLoading.value = false;
+  return arr;
+}
+
+function handleSlideList() {
+  const row = 2;
+  const res: any[] = [];
+  const count = Math.ceil(sliderList.length / row);
+  for (let i = 0, len = sliderList.length; i < len; i += count) {
+    const item = sliderList.slice(i, i + count);
+    res.push([...item]);
+  }
+  interactionList.value = res;
+}
+
+function handleRefresh() {
+  if (currentLive.value) {
+    playLive(currentLive.value);
+  }
+}
+
+function playLive(item: ILive) {
+  handleStopDrawing();
+  canvasRef.value?.childNodes?.forEach((item) => {
+    item.remove();
+  });
+  currentLive.value = item;
+  handlePlay(item.live_room!);
+}
+
+function changeLive(item: ILive) {
+  if (item.id === currentLive.value?.id) return;
+  playLive(item);
+}
+
+async function getLiveRoomList() {
+  try {
+    const res = await fetchLiveList({
+      // orderName: 'created_at',
+      // orderBy: 'desc',
+      childOrderName: 'priority,name',
+      childOrderBy: 'desc,asc',
+      // status: 0,
+      // is_show: 0,
+      // cdn: 0,
+      // is_fake: 0,
+      // live_room_id: 1,
+    });
+    if (res.code === 200) {
+      topLiveRoomList.value = res.data.rows.slice(0, topNums.value);
+      otherLiveRoomList.value = res.data.rows.slice(topNums.value);
+      if (res.data.total) {
+        changeLive(topLiveRoomList.value[0]);
+      }
+    }
+  } catch (error) {
+    console.log(error);
+  }
+}
+
+function joinRoom(data, isBilibili = 'false') {
+  stopPlay();
+  const query = isBilibili === 'true' ? { [URL_QUERY.isBilibili]: 'true' } : {};
+  const url = router.resolve({
+    name: routerName.pull,
+    params: { roomId: data.id },
+    query,
+  });
+  openToTarget(url.href);
+}
+</script>
+
+<style lang="scss" scoped>
+.home-wrap {
+  padding-top: $header-height;
+  .play-container {
+    position: relative;
+    z-index: 1;
+    padding-top: 20px;
+    padding-bottom: 50px;
+    .bg-img {
+      position: absolute;
+      top: 0;
+      right: 0;
+      left: 0;
+      z-index: -1;
+      width: 100%;
+      height: 100%;
+      background-position: center;
+      background-size: cover;
+      background-repeat: no-repeat;
+    }
+    .bg-video {
+      position: absolute;
+      top: 0;
+      right: 0;
+      left: 0;
+      z-index: -1;
+      width: 100%;
+      height: 100%;
+    }
+    .slider-wrap {
+      padding: 4px 0;
+    }
+    .container {
+      display: flex;
+      justify-content: center;
+      box-sizing: border-box;
+      margin: 0 auto;
+      height: calc($w-1150 / $video-ratio);
+
+      .left {
+        position: relative;
+        display: inline-block;
+        overflow: hidden;
+        flex-shrink: 0;
+        box-sizing: border-box;
+        margin-right: 20px;
+        width: $w-1150;
+        height: 100%;
+        border-radius: 4px;
+        background-color: rgba($color: #000000, $alpha: 0.3);
+
+        @extend %coverBg;
+
+        .cdn-ico {
+          position: absolute;
+          top: -9px;
+          right: -10px;
+          z-index: 2;
+          width: 70px;
+          height: 32px;
+          background-color: #f87c48;
+          color: white;
+          transform: rotate(45deg);
+          transform-origin: bottom;
+          .txt {
+            margin-top: 11px;
+            margin-left: 20px;
+            background-image: initial !important;
+            font-size: 14px;
+          }
+        }
+        .logo-watermark {
+          position: absolute;
+          top: 10px;
+          left: 10px;
+          z-index: 2;
+          color: rgba($color: #fff, $alpha: 0.5);
+          font-weight: bold;
+          font-size: 30px;
+          line-height: 1;
+        }
+
+        .cover {
+          position: absolute;
+          background-position: center center;
+          background-size: cover;
+          filter: blur(5px);
+
+          inset: 0;
+        }
+        :deep(canvas) {
+          position: absolute;
+          top: 50%;
+          left: 50%;
+          // min-width: 100%;
+          // min-height: 100%;
+          max-width: $w-1150;
+          max-height: calc($w-1150 / $video-ratio);
+          transform: translate(-50%, -50%);
+
+          user-select: none;
+        }
+        :deep(video) {
+          position: absolute;
+          top: 50%;
+          left: 50%;
+          // min-width: 100%;
+          // min-height: 100%;
+          max-width: $w-1150;
+          max-height: calc($w-1150 / $video-ratio);
+          transform: translate(-50%, -50%);
+
+          user-select: none;
+        }
+        .join-btn {
+          position: absolute;
+          top: 50%;
+          left: 50%;
+          z-index: 20;
+          display: none;
+          align-items: center;
+          justify-content: center;
+          box-sizing: border-box;
+          // width: 80%;
+          transform: translate(-50%, -50%);
+          &.show {
+            display: inline-flex !important;
+          }
+
+          .btn {
+            padding: 14px 26px;
+            border: 2px solid rgba($color: $theme-color-gold, $alpha: 0.5);
+            border-radius: 6px;
+            background-color: rgba(0, 0, 0, 0.3);
+            color: $theme-color-gold;
+            font-size: 16px;
+            cursor: pointer;
+            &:hover {
+              background-color: $theme-color-gold;
+              color: white;
+            }
+          }
+        }
+        .video-controls {
+          display: none;
+        }
+
+        &:hover {
+          .join-btn {
+            display: block;
+          }
+          .video-controls {
+            display: block;
+          }
+        }
+      }
+      .right {
+        display: inline-block;
+        overflow: scroll;
+        flex-shrink: 0;
+        box-sizing: border-box;
+        padding: 12px 10px;
+        height: 100%;
+        border-radius: 4px;
+        background-color: rgba($color: #000000, $alpha: 0.3);
+
+        @extend %hideScrollbar;
+
+        .list {
+          .item {
+            position: relative;
+            box-sizing: border-box;
+            margin-bottom: 10px;
+            width: 200px;
+            height: 110px;
+            border-radius: 4px;
+            background-color: rgba($color: #000000, $alpha: 0.3);
+            cursor: pointer;
+
+            @extend %coverBg;
+
+            &:last-child {
+              margin-bottom: 0;
+            }
+            .hidden {
+              position: relative;
+              overflow: hidden;
+              width: 200px;
+              height: 110px;
+              .cdn-ico {
+                position: absolute;
+                top: -9px;
+                right: -9px;
+                z-index: 2;
+                width: 60px;
+                height: 28px;
+                background-color: #f87c48;
+                color: white;
+                transform: rotate(45deg);
+                transform-origin: bottom;
+
+                .txt {
+                  margin-left: 10px;
+                  background-image: initial !important;
+                  font-size: 12px;
+                  line-height: 0.8;
+                }
+              }
+            }
+
+            .border {
+              position: absolute;
+              top: 0;
+              right: 0;
+              bottom: 0;
+              left: 0;
+              z-index: 3;
+              border: 2px solid $theme-color-gold;
+              border-radius: 4px;
+            }
+            .triangle {
+              position: absolute;
+              top: 50%;
+              left: 0;
+              display: inline-block;
+              border: 5px solid transparent;
+              border-right-color: $theme-color-gold;
+              transform: translate(-100%, -50%);
+            }
+            &.active {
+              &::before {
+                background-color: transparent;
+              }
+            }
+            &:hover {
+              &::before {
+                background-color: transparent;
+              }
+            }
+            &::before {
+              position: absolute;
+              display: block;
+              width: 100%;
+              height: 100%;
+              border-radius: 4px;
+              background-color: rgba(0, 0, 0, 0.4);
+              content: '';
+              transition: all cubic-bezier(0.22, 0.58, 0.12, 0.98) 0.4s;
+            }
+            .txt {
+              position: absolute;
+              bottom: 0;
+              left: 0;
+              box-sizing: border-box;
+              padding: 4px 8px;
+              width: 100%;
+              border-radius: 0 0 4px 4px;
+              background-image: linear-gradient(
+                -180deg,
+                rgba(0, 0, 0, 0),
+                rgba(0, 0, 0, 0.6)
+              );
+              color: white;
+              text-align: initial;
+              font-size: 13px;
+
+              @extend %singleEllipsis;
+            }
+          }
+        }
+        .none {
+          width: 200px;
+          color: white;
+          text-align: center;
+          font-size: 14px;
+        }
+      }
+    }
+  }
+  .area-container {
+    box-sizing: border-box;
+    margin: 10px auto;
+    width: $w-1450;
+
+    .area-item {
+      .title {
+        padding: 10px 0;
+        font-size: 26px;
+      }
+      .live-room-list {
+        display: flex;
+        flex-wrap: wrap;
+        justify-content: space-between;
+        .live-room {
+          display: inline-block;
+          margin-right: 32px;
+          margin-bottom: 10px;
+          width: 300px;
+          cursor: pointer;
+
+          .cover {
+            position: relative;
+            overflow: hidden;
+            width: 100%;
+            height: 150px;
+            border-radius: 8px;
+
+            @extend %containBg;
+
+            .cdn-ico {
+              position: absolute;
+              top: -10px;
+              right: -10px;
+              z-index: 2;
+              width: 70px;
+              height: 28px;
+              background-color: #f87c48;
+              color: white;
+              transform: rotate(45deg);
+              transform-origin: bottom;
+              .txt {
+                margin-left: 18px;
+                background-image: initial !important;
+                font-size: 13px;
+              }
+            }
+
+            .txt {
+              position: absolute;
+              bottom: 0;
+              left: 0;
+              box-sizing: border-box;
+              padding: 4px 8px;
+              width: 100%;
+              border-radius: 0 0 4px 4px;
+              background-image: linear-gradient(
+                -180deg,
+                rgba(0, 0, 0, 0),
+                rgba(0, 0, 0, 0.6)
+              );
+              color: white;
+              text-align: initial;
+              font-size: 13px;
+
+              @extend %singleEllipsis;
+            }
+          }
+          .desc {
+            margin-top: 4px;
+            font-size: 14px;
+
+            @extend %singleEllipsis;
+          }
+        }
+        .null {
+          width: 100%;
+          text-align: center;
+        }
+      }
+    }
+  }
+
+  .foot {
+    margin-top: 10px;
+    text-align: center;
+  }
+}
+
+.ad-wrap-a {
+  position: fixed;
+  top: 300px;
+  left: 10px;
+  // background-color: red;
+  z-index: 999;
+  width: 250px;
+  // height: 150px;
+  border-radius: 10px;
+  ins {
+    width: 100%;
+    height: 100%;
+  }
+}
+
+// 屏幕宽度小于1330的时候
+@media screen and (max-width: 1330px) {
+  .home-wrap {
+    .play-container {
+      .container {
+        height: calc($w-900 / $video-ratio);
+
+        .left {
+          width: $w-900;
+          :deep(canvas) {
+            max-width: $w-900;
+            max-height: calc($w-900 / $video-ratio);
+          }
+          :deep(video) {
+            max-width: $w-900;
+            max-height: calc($w-900 / $video-ratio);
+          }
+        }
+      }
+    }
+    .area-container {
+      width: $w-1150;
+      .area-item {
+        .live-room-list {
+          .live-room {
+            width: 250px;
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 556 - 0
src/views/my/index.vue

@@ -0,0 +1,556 @@
+<template>
+  <div class="my-wrap">
+    <div class="id">用户id:{{ userStore.userInfo?.id }}</div>
+    <div class="avatar">
+      <span class="txt">用户头像:</span>
+      <Avatar
+        :url="userStore.userInfo?.avatar"
+        :name="userStore.userInfo?.username"
+        :size="30"
+      ></Avatar>
+    </div>
+    <div>用户昵称:{{ userStore.userInfo?.username }}</div>
+    <div>
+      用户角色:{{
+        userStore.userInfo?.roles?.map((item) => item.role_name).join(',')
+      }}
+    </div>
+    <div>注册时间:{{ userStore.userInfo?.created_at }}</div>
+
+    <br />
+    <div class="pull-url">
+      <span
+        v-if="!userStore.userInfo?.live_rooms?.length"
+        class="link"
+        @click="openLiveRoom"
+      >
+        未开通
+      </span>
+      <div v-else>
+        <div>
+          直播间地址:
+          <a
+            :href="getLiveRoomPageUrl(userStore.userInfo?.live_rooms?.[0]?.id!)"
+            class="link"
+            target="_blank"
+          >
+            {{ getLiveRoomPageUrl(userStore.userInfo?.live_rooms?.[0]?.id!) }}
+          </a>
+        </div>
+        <div>直播间名称:{{ userStore.userInfo?.live_rooms?.[0]?.name }}</div>
+        <div>
+          直播间简介:{{
+            userStore.userInfo?.live_rooms?.[0]?.desc || '暂无简介'
+          }}
+        </div>
+        <div>
+          直播间分区:{{
+            userStore.userInfo?.live_rooms?.[0]?.areas?.[0]?.name || '暂无分区'
+          }}
+        </div>
+        <div>
+          开通时间:{{ userStore.userInfo?.live_rooms?.[0]?.created_at }}
+        </div>
+
+        <div
+          v-if="
+            userStore.userInfo?.auths?.find(
+              (v) => v.auth_value === DEFAULT_AUTH_INFO.LIVE_PUSH.auth_value
+            )
+          "
+          v-loading="updateKeyLoading"
+          class="url-wrap"
+        >
+          <div
+            class="link"
+            @click="handleUpdateKey"
+          >
+            更新地址
+          </div>
+
+          <div class="srs">
+            <div>
+              <span>
+                RTMP推流地址:{{
+                  userStore.userInfo?.live_rooms?.[0]?.push_rtmp_url!
+                }},
+              </span>
+              <span
+                class="link"
+                @click="
+                  handleCopy(
+                    userStore.userInfo?.live_rooms?.[0]?.push_rtmp_url!
+                  )
+                "
+              >
+                复制
+              </span>
+            </div>
+            <div>
+              <span
+                >OBS服务器:{{
+                  userStore.userInfo?.live_rooms?.[0]?.push_obs_server!
+                }},</span
+              >
+              <span
+                class="link"
+                @click="
+                  handleCopy(
+                    userStore.userInfo?.live_rooms?.[0]?.push_obs_server!
+                  )
+                "
+              >
+                复制
+              </span>
+            </div>
+            <div>
+              <span
+                >OBS推流码:{{
+                  userStore.userInfo?.live_rooms?.[0]?.push_obs_stream_key!
+                }},</span
+              >
+              <span
+                class="link"
+                @click="
+                  handleCopy(
+                    userStore.userInfo?.live_rooms?.[0]?.push_obs_stream_key!
+                  )
+                "
+              >
+                复制
+              </span>
+            </div>
+          </div>
+
+          <br />
+
+          <div>
+            CDN直播:
+            <div
+              v-if="
+                userStore.userInfo?.auths?.find(
+                  (v) =>
+                    v.auth_value === DEFAULT_AUTH_INFO.LIVE_PULL_SVIP.auth_value
+                )
+              "
+              class="cdn"
+            >
+              <div>
+                <span>
+                  RTMP推流地址(CDN):{{
+                    userStore.userInfo?.live_rooms?.[0]?.push_cdn_rtmp_url!
+                  }},
+                </span>
+                <span
+                  class="link"
+                  @click="
+                    handleCopy(
+                      userStore.userInfo?.live_rooms?.[0]?.push_cdn_rtmp_url!
+                    )
+                  "
+                >
+                  复制
+                </span>
+              </div>
+              <div>
+                <span>
+                  OBS服务器(CDN):{{
+                    userStore.userInfo?.live_rooms?.[0]?.push_cdn_obs_server!
+                  }},
+                </span>
+                <span
+                  class="link"
+                  @click="
+                    handleCopy(
+                      userStore.userInfo?.live_rooms?.[0]?.push_cdn_obs_server!
+                    )
+                  "
+                >
+                  复制
+                </span>
+              </div>
+              <div>
+                <span>
+                  OBS推流码(CDN):{{
+                    userStore.userInfo?.live_rooms?.[0]
+                      ?.push_cdn_obs_stream_key!
+                  }},
+                </span>
+                <span
+                  class="link"
+                  @click="
+                    handleCopy(
+                      userStore.userInfo?.live_rooms?.[0]
+                        ?.push_cdn_obs_stream_key!
+                    )
+                  "
+                >
+                  复制
+                </span>
+              </div>
+            </div>
+            <div
+              v-else
+              class="link"
+              @click="router.push({ name: routerName.author })"
+            >
+              请联系作者开通~
+            </div>
+          </div>
+
+          <div>
+            转推b站:
+            <div
+              v-if="
+                userStore.userInfo?.auths?.find(
+                  (v) =>
+                    v.auth_value ===
+                    DEFAULT_AUTH_INFO.LIVE_PUSH_FORWARD_BILIBILI.auth_value
+                )
+              "
+              class="cdn"
+            >
+              <n-input-group>
+                <n-input
+                  v-model:value="liveRoomInfo!.forward_bilibili_url"
+                  style="width: 500px"
+                  type="text"
+                  placeholder="请输入转推b站url"
+                />
+
+                <n-button
+                  type="primary"
+                  ghost
+                  @click="handleUpdateMyLiveRoom()"
+                >
+                  更新
+                </n-button>
+              </n-input-group>
+            </div>
+            <div
+              v-else
+              class="link"
+              @click="router.push({ name: routerName.author })"
+            >
+              请联系作者开通~
+            </div>
+          </div>
+
+          <div>
+            转推虎牙:
+            <div
+              v-if="
+                userStore.userInfo?.auths?.find(
+                  (v) =>
+                    v.auth_value ===
+                    DEFAULT_AUTH_INFO.LIVE_PUSH_FORWARD_HUYA.auth_value
+                )
+              "
+              class="cdn"
+            >
+              <n-input-group>
+                <n-input
+                  v-model:value="liveRoomInfo!.forward_huya_url"
+                  style="width: 500px"
+                  type="text"
+                  placeholder="请输入转推虎牙url"
+                />
+
+                <n-button
+                  type="primary"
+                  ghost
+                  @click="handleUpdateMyLiveRoom()"
+                >
+                  更新
+                </n-button>
+              </n-input-group>
+            </div>
+            <div
+              v-else
+              class="link"
+              @click="router.push({ name: routerName.author })"
+            >
+              请联系作者开通~
+            </div>
+          </div>
+
+          <div>
+            转推斗鱼:
+            <div
+              v-if="
+                userStore.userInfo?.auths?.find(
+                  (v) =>
+                    v.auth_value ===
+                    DEFAULT_AUTH_INFO.LIVE_PUSH_FORWARD_DOUYU.auth_value
+                )
+              "
+              class="cdn"
+            >
+              <n-input-group>
+                <n-input
+                  v-model:value="liveRoomInfo!.forward_douyu_url"
+                  style="width: 500px"
+                  type="text"
+                  placeholder="请输入转推斗鱼url"
+                />
+
+                <n-button
+                  type="primary"
+                  ghost
+                  @click="handleUpdateMyLiveRoom()"
+                >
+                  更新
+                </n-button>
+              </n-input-group>
+            </div>
+            <div
+              v-else
+              class="link"
+              @click="router.push({ name: routerName.author })"
+            >
+              请联系作者开通~
+            </div>
+          </div>
+
+          <div>
+            转推抖音:
+            <div
+              v-if="
+                userStore.userInfo?.auths?.find(
+                  (v) =>
+                    v.auth_value ===
+                    DEFAULT_AUTH_INFO.LIVE_PUSH_FORWARD_DOUYIN.auth_value
+                )
+              "
+              class="cdn"
+            >
+              <n-input-group>
+                <n-input
+                  v-model:value="liveRoomInfo!.forward_douyin_url"
+                  style="width: 500px"
+                  type="text"
+                  placeholder="请输入转推抖音url"
+                />
+
+                <n-button
+                  type="primary"
+                  ghost
+                  @click="handleUpdateMyLiveRoom()"
+                >
+                  更新
+                </n-button>
+              </n-input-group>
+            </div>
+            <div
+              v-else
+              class="link"
+              @click="router.push({ name: routerName.author })"
+            >
+              请联系作者开通~
+            </div>
+          </div>
+
+          <div>
+            转推小红书:
+            <div
+              v-if="
+                userStore.userInfo?.auths?.find(
+                  (v) =>
+                    v.auth_value ===
+                    DEFAULT_AUTH_INFO.LIVE_PUSH_FORWARD_XIAOHONGSHU.auth_value
+                )
+              "
+              class="cdn"
+            >
+              <n-input-group>
+                <n-input
+                  v-model:value="liveRoomInfo!.forward_xiaohongshu_url"
+                  style="width: 500px"
+                  type="text"
+                  placeholder="请输入转推小红书url"
+                />
+
+                <n-button
+                  type="primary"
+                  ghost
+                  @click="handleUpdateMyLiveRoom()"
+                >
+                  更新
+                </n-button>
+              </n-input-group>
+            </div>
+            <div
+              v-else
+              class="link"
+              @click="router.push({ name: routerName.author })"
+            >
+              请联系作者开通~
+            </div>
+          </div>
+
+          <div>
+            转推快手:
+            <div
+              v-if="
+                userStore.userInfo?.auths?.find(
+                  (v) =>
+                    v.auth_value ===
+                    DEFAULT_AUTH_INFO.LIVE_PUSH_FORWARD_KUAISHOU.auth_value
+                )
+              "
+              class="cdn"
+            >
+              <n-input-group>
+                <n-input
+                  v-model:value="liveRoomInfo!.forward_kuaishou_url"
+                  style="width: 500px"
+                  type="text"
+                  placeholder="请输入转推快手url"
+                />
+
+                <n-button
+                  type="primary"
+                  ghost
+                  @click="handleUpdateMyLiveRoom()"
+                >
+                  更新
+                </n-button>
+              </n-input-group>
+            </div>
+            <div
+              v-else
+              class="link"
+              @click="router.push({ name: routerName.author })"
+            >
+              请联系作者开通~
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { copyToClipBoard, openToTarget } from 'billd-utils';
+import { ref, watch } from 'vue';
+import { useRouter } from 'vue-router';
+
+import { fetchUpdateLiveRoomKey, fetchUpdateMyLiveRoom } from '@/api/liveRoom';
+import { DEFAULT_AUTH_INFO, URL_QUERY } from '@/constant';
+import { loginTip } from '@/hooks/use-login';
+import { routerName } from '@/router';
+import { useUserStore } from '@/store/user';
+import { ILiveRoom, LiveRoomTypeEnum } from '@/types/ILiveRoom';
+import { getLiveRoomPageUrl } from '@/utils';
+
+const userStore = useUserStore();
+const router = useRouter();
+const liveRoomInfo = ref<ILiveRoom>();
+
+const updateKeyLoading = ref(false);
+
+function handleCopy(url: string) {
+  copyToClipBoard(url);
+  window.$message.success('复制成功!');
+}
+
+watch(
+  () => userStore.userInfo,
+  (newval) => {
+    if (newval?.live_rooms?.[0]) {
+      liveRoomInfo.value = newval?.live_rooms[0];
+    }
+  },
+  {
+    immediate: true,
+  }
+);
+
+async function handleUpdateMyLiveRoom() {
+  const res = await fetchUpdateMyLiveRoom({
+    forward_bilibili_url: liveRoomInfo.value?.forward_bilibili_url,
+    forward_douyin_url: liveRoomInfo.value?.forward_douyin_url,
+    forward_douyu_url: liveRoomInfo.value?.forward_douyu_url,
+    forward_huya_url: liveRoomInfo.value?.forward_huya_url,
+    forward_kuaishou_url: liveRoomInfo.value?.forward_kuaishou_url,
+    forward_xiaohongshu_url: liveRoomInfo.value?.forward_xiaohongshu_url,
+  });
+  if (res.code === 200) {
+    window.$message.success('修改成功!');
+  }
+}
+
+function openLiveRoom() {
+  if (!loginTip()) {
+    return;
+  }
+  const url = router.resolve({
+    name: routerName.push,
+    query: { [URL_QUERY.liveType]: LiveRoomTypeEnum.srs },
+  });
+  openToTarget(url.href);
+}
+
+async function handleUpdateKey() {
+  try {
+    updateKeyLoading.value = true;
+    const res = await fetchUpdateLiveRoomKey();
+    if (res.code === 200 && userStore.userInfo?.live_rooms?.[0]) {
+      userStore.userInfo.live_rooms[0].push_obs_server =
+        res.data.srsPushRes.obs_server;
+      userStore.userInfo.live_rooms[0].push_obs_stream_key =
+        res.data.srsPushRes.obs_stream_key;
+      userStore.userInfo.live_rooms[0].push_rtmp_url =
+        res.data.srsPushRes.rtmp_url;
+      userStore.userInfo.live_rooms[0].push_srt_url =
+        res.data.srsPushRes.srt_url;
+      userStore.userInfo.live_rooms[0].push_webrtc_url =
+        res.data.srsPushRes.webrtc_url;
+      userStore.userInfo.live_rooms[0].push_cdn_obs_server =
+        res.data.cdnPushRes.obs_server;
+      userStore.userInfo.live_rooms[0].push_cdn_obs_stream_key =
+        res.data.cdnPushRes.obs_stream_key;
+      userStore.userInfo.live_rooms[0].push_cdn_rtmp_url =
+        res.data.cdnPushRes.rtmp_url;
+      userStore.userInfo.live_rooms[0].push_cdn_srt_url =
+        res.data.cdnPushRes.srt_url;
+      userStore.userInfo.live_rooms[0].push_cdn_webrtc_url =
+        res.data.cdnPushRes.webrtc_url;
+    }
+  } catch (error) {
+    console.error(error);
+  } finally {
+    updateKeyLoading.value = false;
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.my-wrap {
+  position: relative;
+  padding: 10px;
+  .link {
+    display: inline-block;
+    color: $theme-color-gold;
+    text-decoration: none;
+    cursor: pointer;
+
+    user-select: none;
+  }
+  .avatar {
+    display: flex;
+    align-items: center;
+    .txt {
+      margin-right: 10px;
+    }
+  }
+  .url-wrap {
+    position: relative;
+    margin-top: 10px;
+    .cdn {
+      margin-bottom: 10px;
+    }
+  }
+}
+</style>

+ 31 - 0
src/views/notFound.vue

@@ -0,0 +1,31 @@
+<template>
+  <div class="notFound-wrap">
+    404,
+    <span
+      class="click"
+      @click="router.push('/')"
+    >
+      点我
+    </span>
+    回首页
+  </div>
+</template>
+
+<script lang="ts" setup>
+import router from '@/router';
+</script>
+
+<style lang="scss" scoped>
+.notFound-wrap {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  font-size: 30px;
+  transform: translate(-50%, -50%);
+  .click {
+    color: $theme-color-gold;
+    text-decoration: underline;
+    cursor: pointer;
+  }
+}
+</style>

+ 118 - 0
src/views/oauth/index.vue

@@ -0,0 +1,118 @@
+<template>
+  <div v-if="!errMsg.length">{{ currentOauth }}登录...</div>
+  <div v-else>非法登录!{{ errMsg }}</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { fetchWechatLogin } from '@/api/wechatUser';
+import { handleQQLogin } from '@/hooks/use-login';
+import { PlatformEnum } from '@/interface';
+import router, { routerName } from '@/router';
+import { useUserStore } from '@/store/user';
+import { clearThirdLoginInfo, getThirdLoginInfo } from '@/utils/cookie';
+
+const route = useRoute();
+const userStore = useUserStore();
+
+const errMsg = ref('');
+const currentOauth = ref('');
+
+onMounted(async () => {
+  const { platform } = route.params;
+  const { code, state } = route.query;
+  if (!code) {
+    errMsg.value = '地址栏缺少code';
+    return;
+  }
+  if (!state) {
+    errMsg.value = '地址栏缺少state';
+    return;
+  }
+
+  const atobStateRes = window.atob(window.decodeURIComponent(state as string));
+  let loginInfo = '';
+
+  try {
+    const res = JSON.parse(atobStateRes);
+    // 在第三方登录的时候,会往cookie里记录环境,因此这里直接读取
+    // 如果不是dev环境,则读取cookie
+    if (!res.dev) {
+      loginInfo = getThirdLoginInfo();
+      if (!loginInfo) {
+        errMsg.value = 'cookie缺少登录信息';
+        return;
+      }
+
+      if (state !== window.btoa(window.decodeURIComponent(loginInfo))) {
+        errMsg.value = 'state非法';
+        return;
+      }
+    } else {
+      loginInfo = atobStateRes;
+    }
+  } catch (error) {
+    errMsg.value = '校验state错误';
+    return;
+  }
+
+  switch (platform) {
+    case PlatformEnum.qqLogin:
+      currentOauth.value = 'QQ';
+      break;
+    case PlatformEnum.wechatLogin:
+      currentOauth.value = '微信';
+      break;
+  }
+
+  try {
+    const { isMobile, env, qrcodePlatform, qrcodeExp, qrcodeLoginId, qqExp } =
+      JSON.parse(loginInfo);
+
+    if (env === 'qq') {
+      const info = { type: PlatformEnum.qqLogin, data: { code, qqExp } };
+      if (isMobile) {
+        try {
+          const flag = await handleQQLogin({
+            data: info,
+          });
+          if (flag) {
+            router.push({ name: routerName.h5 });
+          }
+        } catch (error) {
+          console.log(error);
+        }
+      } else {
+        window.opener.postMessage(info, '*');
+        window.close();
+      }
+    } else if (env === 'wechat') {
+      if (!qrcodePlatform || !qrcodeLoginId || !qrcodeExp) {
+        window.$message.error('参数缺失!');
+        return;
+      }
+      const res = await fetchWechatLogin({
+        code,
+        platform: qrcodePlatform,
+        login_id: qrcodeLoginId,
+        exp: qrcodeExp,
+      });
+      if (res.code === 200) {
+        window.$message.success('登录成功!');
+        userStore.setToken(res.data, qrcodeExp);
+        userStore.getUserInfo();
+        router.push({ name: routerName.h5 });
+      } else {
+        window.$message.error(res.msg);
+      }
+    }
+  } catch (error) {
+    console.log(error);
+    clearThirdLoginInfo();
+  }
+});
+</script>
+
+<style lang="scss" scoped></style>

+ 157 - 0
src/views/order/index.vue

@@ -0,0 +1,157 @@
+<template>
+  <div class="order-wrap">
+    <div class="list">
+      <div class="head-wrap">
+        <div
+          v-for="(item, index) in headList"
+          :key="index"
+          class="head"
+        >
+          <div>{{ item.label }}</div>
+        </div>
+      </div>
+
+      <div
+        v-for="(item, index) in payList"
+        :key="index"
+        class="item"
+      >
+        <div class="time">{{ item.created_at }}</div>
+        <div class="user">
+          <template v-if="item.user">
+            <img
+              v-lazy="item.user.avatar"
+              class="avatar"
+              alt=""
+            />
+            <span class="username">{{ item.user.username }}</span>
+          </template>
+          <span v-else>游客</span>
+        </div>
+
+        <div class="account">{{ item.buyer_logon_id }}</div>
+        <div class="gift">{{ item.billd_live_order_subject }}</div>
+        <div class="gift">{{ item.total_amount }}元</div>
+        <div class="status">
+          {{
+            item.trade_status === PayStatusEnum.WAIT_BUYER_PAY
+              ? '支付中'
+              : '已支付'
+          }}
+        </div>
+        <div class="time">{{ item.send_pay_date || '-' }}</div>
+      </div>
+      <div v-if="!payList.length">{{ t('common.nonedata') }}</div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+import { fetchOrderList } from '@/api/order';
+import { fullLoading } from '@/components/FullLoading';
+import { IOrder, PayStatusEnum } from '@/interface';
+
+const payList = ref<IOrder[]>([]);
+const { t } = useI18n();
+
+const headList = ref([
+  {
+    label: '创建时间',
+    key: 'created_at',
+  },
+  {
+    label: '用户',
+    key: 'avatar',
+  },
+  {
+    label: '支付宝账号',
+    key: 'buyer_logon_id',
+  },
+  {
+    label: '商品',
+    key: 'subject',
+  },
+  {
+    label: '价格',
+    key: 'total_amount',
+  },
+  {
+    label: '状态',
+    key: 'trade_status',
+  },
+  {
+    label: '完成时间',
+    key: 'send_pay_date',
+  },
+]);
+
+onUnmounted(() => {});
+
+onMounted(() => {
+  getPayList();
+});
+
+async function getPayList() {
+  try {
+    fullLoading({ loading: true });
+    const res = await fetchOrderList({
+      trade_status: PayStatusEnum.TRADE_SUCCESS,
+    });
+    if (res.code === 200) {
+      payList.value = res.data.rows;
+    }
+  } catch (error) {
+    console.log(error);
+  } finally {
+    fullLoading({ loading: false });
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.order-wrap {
+  padding: 50px 30px 0;
+  padding-top: calc($header-height + 10px);
+
+  .list {
+    text-align: center;
+
+    .head-wrap {
+      display: flex;
+      align-items: center;
+      .head {
+        flex: 1;
+        box-sizing: border-box;
+        margin-bottom: 5px;
+      }
+    }
+    .item {
+      display: flex;
+      align-items: center;
+      height: 40px;
+      color: #666;
+      > div {
+        display: flex;
+        align-items: center;
+        flex: 1;
+        justify-content: center;
+      }
+      &:nth-child(2n) {
+        background-color: #fafbfc;
+      }
+      .time {
+      }
+      .user {
+        .avatar {
+          width: 30px;
+          height: 30px;
+          border-radius: 50%;
+        }
+      }
+    }
+  }
+}
+</style>

+ 582 - 0
src/views/privatizationDeployment/index.vue

@@ -0,0 +1,582 @@
+<template>
+  <div class="privatizationDeployment-wrap">
+    <h2 class="title">
+      <div
+        v-for="(item, index) in detail[currentTab].slogan"
+        :key="index"
+      >
+        {{ item }}
+      </div>
+    </h2>
+    <div class="tab">
+      <div
+        v-for="(item, index) in tab"
+        :key="index"
+        class="item"
+        :class="{ active: item.id === currentTab }"
+        @click="currentTab = item.id"
+      >
+        {{ item.txt }}
+      </div>
+    </div>
+    <div class="list">
+      <div
+        v-for="(item, index) in detail[currentTab].list"
+        :key="index"
+        class="item"
+        :class="{ [item['color']]: 1 }"
+      >
+        <div class="name">{{ item.name }}</div>
+        <div class="desc">{{ item.desc }}</div>
+        <div class="price">
+          <span class="t1">{{ item.price.left }}</span>
+          <span class="t2">{{ item.price.center }}</span>
+          <span class="t3">{{ item.price.right }}</span>
+        </div>
+        <div class="feat">
+          <div
+            v-if="item.tip !== ''"
+            class="feat-item tip"
+          >
+            {{ item.tip }}
+          </div>
+          <div
+            v-for="(iten, indey) in item.feat"
+            :key="indey"
+            class="feat-item"
+          >
+            <div
+              :class="{
+                done: iten.status === 'done',
+                todo: iten.status === 'todo',
+              }"
+            ></div>
+            <div class="txt">{{ iten.txt }}</div>
+          </div>
+        </div>
+        <div
+          class="btn"
+          @click="handleClick(item.btn)"
+        >
+          {{ item.btn.txt }}
+        </div>
+      </div>
+    </div>
+  </div>
+  <n-modal v-model:show="showContach">
+    <n-card
+      style="width: 400px"
+      title="联系作者"
+      role="dialog"
+      closable
+      @close="showContach = false"
+    >
+      <div>
+        <div>微信二维码:</div>
+        <img
+          src="@/assets/img/my-wechat.webp"
+          alt=""
+          style="width: 300px"
+        />
+        <div>微信号:{{ AUTHOR_INFO.wechat }}</div>
+        <div>qq号:{{ AUTHOR_INFO.qq }}</div>
+        <div>添加时请备注:<b>live私有化部署</b></div>
+        <b style="color: red">
+          <span>
+            注意:项目开源,个人用户有问题github提Issue即可,不必添加作者!
+          </span>
+          <br />
+          <i>如有商业合作,请充分了解该项目。咨询需付费(100元/小时)!</i>
+        </b>
+      </div>
+    </n-card>
+  </n-modal>
+</template>
+
+<script lang="ts" setup>
+import { openToTarget } from 'billd-utils';
+import { ref } from 'vue';
+import { useRouter } from 'vue-router';
+
+import { AUTHOR_INFO, COMMON_URL, PROJECT_NAME } from '@/constant';
+import { routerName } from '@/router';
+
+const router = useRouter();
+const showContach = ref(false);
+const currentTab = ref<'personal' | 'openSource' | 'customized' | string>(
+  'openSource'
+);
+
+const tab = ref([
+  {
+    id: 'personal',
+    txt: '个人版',
+  },
+  {
+    id: 'openSource',
+    txt: '开源版',
+  },
+  {
+    id: 'customized',
+    txt: '定制版',
+  },
+]);
+
+const detail = ref({
+  personal: {
+    slogan: ['欢迎使用billd直播~'],
+    list: [
+      {
+        color: 'blue',
+        name: 'VIP',
+        desc: '适用于个人用户简单体验',
+        price: {
+          left: '¥',
+          center: '0',
+          right: '',
+        },
+        tip: '',
+        feat: [
+          {
+            status: 'done',
+            txt: 'SRS直播',
+          },
+          {
+            status: 'done',
+            txt: '打PK直播',
+          },
+          {
+            status: 'done',
+            txt: 'WebRTC直播',
+          },
+          {
+            status: 'done',
+            txt: 'WebRTC会议',
+          },
+        ],
+        btn: {
+          type: 'push',
+          link: routerName.push,
+          txt: '免费体验',
+        },
+      },
+      {
+        color: 'green',
+        name: 'SVIP',
+        desc: '适用于个人用户中度体验',
+        price: {
+          left: '¥',
+          center: '10',
+          right: '元/月',
+        },
+        tip: '涵盖VIP全部功能',
+        feat: [
+          {
+            status: 'done',
+            txt: '转推b站',
+          },
+          {
+            status: 'done',
+            txt: '转推虎牙',
+          },
+          {
+            status: 'done',
+            txt: '去广告',
+          },
+        ],
+        btn: {
+          type: 'toast',
+          link: '即将上线~',
+          txt: '立即购买',
+        },
+      },
+      {
+        color: 'orange',
+        name: 'ADMIN',
+        desc: '适用于个人用户深度体验',
+        price: {
+          left: '¥',
+          center: '50',
+          right: '元/月',
+        },
+        tip: '涵盖SVIP全部功能',
+        feat: [
+          {
+            status: 'done',
+            txt: 'Msr直播',
+          },
+          {
+            status: 'done',
+            txt: '腾讯云直播(CDN)',
+          },
+          {
+            status: 'done',
+            txt: '腾讯云打PK(CDN)',
+          },
+        ],
+        btn: {
+          type: 'toast',
+          link: '即将上线~',
+          txt: '立即购买',
+        },
+      },
+    ],
+  },
+  openSource: {
+    slogan: ['billd直播开源至今,累计收获1.3k+ star', '值得信赖,欢迎部署~'],
+    list: [
+      {
+        color: 'blue',
+        name: 'Github',
+        desc: '适用于个人学习/测试用途',
+        price: {
+          left: '¥',
+          center: '0',
+          right: '',
+        },
+        tip: '',
+        feat: [
+          {
+            status: 'done',
+            txt: '源码开源,自行部署',
+          },
+          {
+            status: 'done',
+            txt: '前台(Web)',
+          },
+          {
+            status: 'done',
+            txt: '后台(Web)',
+          },
+          {
+            status: 'done',
+            txt: '后端(Node.js)',
+          },
+          {
+            status: 'done',
+            txt: '移动端(Flutter)',
+          },
+          {
+            status: 'todo',
+            txt: '客户端(Electron)',
+          },
+        ],
+        btn: {
+          type: 'link',
+          link: 'https://github.com/billd-project/live',
+          txt: '立即部署',
+        },
+      },
+      {
+        color: 'green',
+        name: '私有化部署',
+        desc: '适用于个人/企业自建直播平台',
+        price: {
+          left: '¥',
+          center: '8000',
+          right: '起',
+        },
+        tip: '涵盖Github全部/部分功能',
+        feat: [
+          {
+            status: 'done',
+            txt: '一次部署,永久使用',
+          },
+          {
+            status: 'done',
+            txt: '无门槛,全程专人负责部署',
+          },
+          {
+            status: 'done',
+            txt: '本地服务器部署',
+          },
+          {
+            status: 'done',
+            txt: '快速上线',
+          },
+        ],
+        btn: {
+          type: 'showContact',
+          link: '',
+          txt: '立即咨询',
+        },
+      },
+    ],
+  },
+  customized: {
+    slogan: ['billd直播支持定制化', '立即定制自己的个性化直播间~'],
+    list: [
+      {
+        color: 'blue',
+        name: '在线咨询',
+        desc: '咨询/答疑服务',
+        price: {
+          left: '¥',
+          center: '100',
+          right: '元/小时',
+        },
+        tip: '',
+        feat: [
+          {
+            status: 'done',
+            txt: '一对一解答',
+          },
+        ],
+        btn: {
+          type: 'showContact',
+          link: '',
+          txt: '立即咨询',
+        },
+      },
+      {
+        color: 'green',
+        name: '付费课程',
+        desc: '适用于前端/音视频小白',
+        price: {
+          left: '¥',
+          center: '399',
+          right: '元',
+        },
+        tip: '',
+        feat: [
+          {
+            status: 'done',
+            txt: '一对一解答(4小时)',
+          },
+          {
+            status: 'done',
+            txt: '能够搭建最基础的直播间',
+          },
+          {
+            status: 'done',
+            txt: '单独的代码仓库',
+          },
+          {
+            status: 'done',
+            txt: '视频讲解',
+          },
+          {
+            status: 'done',
+            txt: `${PROJECT_NAME}付费课微信群`,
+          },
+        ],
+        btn: {
+          type: 'link',
+          link: COMMON_URL.payCoursesArticle,
+          txt: '了解详情',
+        },
+      },
+      {
+        color: 'orange',
+        name: '私有化部署',
+        desc: '适用于个人/企业自建直播平台',
+        price: {
+          left: '¥',
+          center: '9999',
+          right: '起',
+        },
+        tip: '',
+        feat: [
+          {
+            status: 'done',
+            txt: '一次部署,永久使用',
+          },
+          {
+            status: 'done',
+            txt: '无门槛,全程专人负责部署',
+          },
+          {
+            status: 'done',
+            txt: '本地服务器部署',
+          },
+          {
+            status: 'done',
+            txt: '快速上线',
+          },
+          {
+            status: 'done',
+            txt: '定制化功能',
+          },
+        ],
+        btn: {
+          type: 'showContact',
+          link: '',
+          txt: '立即咨询',
+        },
+      },
+    ],
+  },
+});
+
+function handleClick(item) {
+  if (item.type === 'link') {
+    openToTarget(item.link);
+  } else if (item.type === 'push') {
+    const url = router.resolve({
+      name: item.link,
+    });
+    openToTarget(url.href);
+  } else if (item.type === 'buy') {
+    console.log('buy');
+  } else if (item.type === 'toast') {
+    window.$message.info(item.link);
+  } else if (item.type === 'showContact') {
+    showContach.value = true;
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.privatizationDeployment-wrap {
+  height: calc(100vh - $layout-head-h);
+  background-color: #f4f8ff;
+  .title {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    box-sizing: border-box;
+    margin: 0 auto;
+    width: 1200px;
+    height: 180px;
+    // background-color: red;
+    text-align: center;
+    font-size: 40px;
+  }
+  .tab {
+    display: flex;
+    justify-content: center;
+    margin: 0 auto;
+    padding: 8px 0;
+    width: 320px;
+    border-radius: 40px;
+    background: white;
+    box-shadow: 0 3px 6px rgba(0, 0, 0, 0.05);
+
+    user-select: none;
+    .item {
+      padding: 4px 25px;
+      border-radius: 40px;
+      color: #686e88;
+      font-weight: 700;
+      font-size: 16px;
+      cursor: pointer;
+      &.active {
+        background-color: $theme-color-gold;
+        color: white;
+      }
+    }
+  }
+  .list {
+    display: flex;
+    justify-content: center;
+    margin: 50px auto 0;
+    width: 1200px;
+    .item {
+      box-sizing: border-box;
+      margin: 0 10px;
+      padding: 20px 20px;
+      width: 240px;
+      // border: 1px solid #dde6ed;
+      border-radius: 2px;
+      border-top-left-radius: 4px;
+      border-top-right-radius: 4px;
+      background-color: white;
+      font-size: 14px;
+
+      &.blue {
+        border-top: 7px solid #38c0ff;
+      }
+      &.green {
+        border-top: 7px solid #30d1aa;
+      }
+      &.orange {
+        border-top: 7px solid #ffbd33;
+      }
+      .name {
+        padding: 10px 0 0;
+        height: 40px;
+        text-align: center;
+        font-size: 30px;
+        line-height: 1;
+      }
+      .desc {
+        margin-top: 10px;
+        height: 40px;
+        color: #88898d;
+        text-align: center;
+        // background-color: red;
+      }
+      .price {
+        display: flex;
+        align-items: flex-end;
+        justify-content: center;
+        height: 45px;
+        color: #88898d;
+        text-align: center;
+        .t1 {
+          color: #272727;
+          font-weight: 600;
+          font-size: 16px;
+        }
+        .t2 {
+          color: #272727;
+          font-size: 40px;
+          line-height: 36px;
+        }
+        .t3 {
+          color: #2c2c2c;
+          font-size: 16px;
+        }
+        // background-color: red;
+      }
+      .feat {
+        margin-top: 30px;
+        height: 200px;
+        .feat-item {
+          display: flex;
+          align-items: center;
+          margin-bottom: 10px;
+          &.tip {
+            color: #88898d;
+          }
+          .todo,
+          .done {
+            margin-right: 10px;
+            width: 18px;
+            height: 18px;
+            text-align: center;
+          }
+          .todo {
+            position: relative;
+            &::after {
+              color: #ffc049;
+              content: '-';
+              text-align: center;
+              font-size: 16px;
+            }
+          }
+          .done {
+            @include setBackground('@/assets/img/check.png');
+          }
+        }
+      }
+      .btn {
+        margin: 0 auto;
+        padding: 8px 0;
+        width: 160px;
+        border: 1px solid $theme-color-gold;
+        border-radius: 4px;
+        color: $theme-color-gold;
+        text-align: center;
+        font-size: 16px;
+        cursor: pointer;
+        transition: all 00.3s ease;
+        &:hover {
+          background-color: $theme-color-gold;
+          color: white;
+        }
+      }
+    }
+  }
+}
+</style>

+ 629 - 0
src/views/rank/index.vue

@@ -0,0 +1,629 @@
+<template>
+  <div class="rank-wrap">
+    <div class="type-list">
+      <div
+        v-for="(item, index) in rankTypeList"
+        :key="index"
+        :class="{ item: 1, active: item.type === currRankType }"
+        @click="changeCurrRankType(item.type)"
+      >
+        {{ t(item.label) }}
+      </div>
+    </div>
+
+    <div
+      v-if="rankList.length"
+      v-loading="loading"
+      class="rank-list"
+    >
+      <div class="top">
+        <div
+          v-for="(item, index) in [
+            rankList[1],
+            rankList[0],
+            rankList[2],
+          ].filter((v) => v !== undefined)"
+          :key="currRankType + '-' + index"
+          :class="{ item: 1, [`rank-${item.rank}`]: 1 }"
+        >
+          <div
+            class="avatar"
+            @click="
+              currRankType !== RankTypeEnum.blog && handleJump(item.users[0])
+            "
+          >
+            <Avatar
+              :size="100"
+              :url="item.users[0]?.avatar"
+              :name="item.users[0]?.username"
+              :living="!!item.live?.live"
+            ></Avatar>
+          </div>
+          <div class="username">{{ item.users[0]?.username }}</div>
+          <div class="rank">
+            <i>0{{ item.rank }}</i>
+            <div
+              v-if="item.live?.live && currRankType === RankTypeEnum.liveRoom"
+              class="living"
+              @click="handleJoin(item.live)"
+            >
+              {{ t('common.living') }}
+            </div>
+          </div>
+          <div
+            v-if="currRankType === RankTypeEnum.wallet"
+            class="wallet"
+          >
+            <span>{{ t('common.wallet') }}: </span>
+            <span>{{ formatMoney(item.balance) }}¥</span>
+          </div>
+          <div
+            v-if="currRankType === RankTypeEnum.signin"
+            class="signin"
+          >
+            <span>
+              {{ t('rank.accumulatedSignin', { nums: item.signin_nums }) }}
+            </span>
+          </div>
+        </div>
+      </div>
+      <div class="top50-list">
+        <div
+          v-for="(item, index) in rankList.filter((_item, index) => index >= 3)"
+          :key="index"
+          class="top50-item"
+        >
+          <div class="rank">
+            <i>{{ item.rank >= 10 ? item.rank : '0' + item.rank }}</i>
+          </div>
+          <div
+            class="left"
+            @click="
+              currRankType !== RankTypeEnum.blog && handleJump(item.users[0])
+            "
+          >
+            <Avatar
+              :size="28"
+              :url="item.users[0]?.avatar"
+              :name="item.users[0]?.username"
+              :living="!!item.live?.live"
+              disableLiving
+            ></Avatar>
+            <div class="username">{{ item.users[0]?.username }}</div>
+            <div
+              v-if="currRankType === RankTypeEnum.wallet"
+              class="wallet"
+            >
+              <span>{{ t('common.wallet') }}: </span>
+              <span>{{ formatMoney(item.balance) }}¥</span>
+            </div>
+            <div
+              v-if="currRankType === RankTypeEnum.signin"
+              class="signin"
+            >
+              <span>
+                ({{
+                  t('rank.accumulatedSignin', { nums: item.signin_nums })
+                }})
+              </span>
+            </div>
+            <div
+              v-if="item.live?.live && currRankType === RankTypeEnum.liveRoom"
+              class="living-tag"
+              @click.stop="handleJoin(item.live)"
+            >
+              {{ t('common.living') }}
+            </div>
+          </div>
+          <div class="right"></div>
+        </div>
+      </div>
+    </div>
+    <div
+      v-if="!rankList.length && !loading"
+      class="null"
+    >
+      暂无数据
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { openToTarget } from 'billd-utils';
+import { onMounted, reactive, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+import { fetchLiveRoomList } from '@/api/liveRoom';
+import { fetchSigninList } from '@/api/signin';
+import { fetchUserList } from '@/api/user';
+import { fetchWalletList } from '@/api/wallet';
+import { RankTypeEnum, SwitchEnum } from '@/interface';
+import router, { routerName } from '@/router';
+import { useUserStore } from '@/store/user';
+import { ILiveRoom } from '@/types/ILiveRoom';
+import { formatMoney } from '@/utils';
+
+const userStore = useUserStore();
+export interface IRankType {
+  type: RankTypeEnum;
+  label: string;
+}
+
+const rankTypeList = ref<IRankType[]>([
+  {
+    type: RankTypeEnum.liveRoom,
+    label: 'rank.liveRank',
+  },
+  {
+    type: RankTypeEnum.user,
+    label: 'rank.userRank',
+  },
+  {
+    type: RankTypeEnum.wallet,
+    label: 'rank.richRank',
+  },
+  {
+    type: RankTypeEnum.signin,
+    label: 'rank.signinRank',
+  },
+  // {
+  //   type: RankTypeEnum.blog,
+  //   label: '博客用户',
+  // },
+]);
+
+const mockDataNums = 4;
+
+const pageParams = reactive({
+  nowPage: 1,
+  pageSize: 50,
+});
+
+const currRankType = ref(RankTypeEnum.liveRoom);
+const { t } = useI18n();
+const loading = ref(true);
+const mockRank: {
+  users: { id; username; avatar }[];
+  rank: number;
+  level: number;
+  score: number;
+  balance: number;
+  signin_nums: number;
+  live?: ILiveRoom;
+}[] = [
+  // {
+  //   users: [
+  //     {
+  //       id: -1,
+  //       username: '待上榜',
+  //       avatar: '',
+  //     },
+  //   ],
+  //   rank: 1,
+  //   level: 0,
+  //   score: 0,
+  //   balance: 0,
+  //   signin_nums: 0,
+  //   live: undefined,
+  // },
+  // {
+  //   users: [
+  //     {
+  //       id: -1,
+  //       username: '待上榜',
+  //       avatar: '',
+  //     },
+  //   ],
+  //   rank: 2,
+  //   level: 0,
+  //   score: 0,
+  //   balance: 0,
+  //   signin_nums: 0,
+  //   live: undefined,
+  // },
+  // {
+  //   users: [
+  //     {
+  //       id: -1,
+  //       username: '待上榜',
+  //       avatar: '',
+  //     },
+  //   ],
+  //   rank: 3,
+  //   level: 0,
+  //   score: 0,
+  //   balance: 0,
+  //   signin_nums: 0,
+  //   live: undefined,
+  // },
+  // {
+  //   users: [
+  //     {
+  //       id: -1,
+  //       username: '待上榜',
+  //       avatar: '',
+  //     },
+  //   ],
+  //   rank: 4,
+  //   level: 0,
+  //   score: 0,
+  //   balance: 0,
+  //   signin_nums: 0,
+  //   live: undefined,
+  // },
+];
+const rankList = ref(mockRank);
+
+function handleJump(item) {
+  if (userStore.userInfo?.id === item.id) {
+    router.push({
+      name: routerName.my,
+    });
+  } else {
+    router.push({
+      name: routerName.user,
+      params: { id: item.id },
+    });
+  }
+}
+
+function handleJoin(item) {
+  const url = router.resolve({
+    name: routerName.pull,
+    params: { roomId: item.live.live_room_id },
+  });
+  openToTarget(url.href);
+}
+
+async function getWalletList() {
+  try {
+    loading.value = true;
+    const res = await fetchWalletList({
+      ...pageParams,
+      orderName: 'balance',
+      orderBy: 'desc',
+    });
+    if (res.code === 200) {
+      const length = res.data.rows.length;
+      rankList.value = res.data.rows.map((item, index) => {
+        return {
+          users: [
+            {
+              id: item.user?.id,
+              username: item.user?.username,
+              avatar: item.user?.avatar || '',
+            },
+          ],
+          rank: index + 1,
+          level: 0,
+          score: 0,
+          balance: item.balance || 0,
+          signin_nums: 0,
+        };
+      });
+      if (length < mockDataNums) {
+        rankList.value.push(...mockRank.slice(length));
+      }
+    }
+  } catch (error) {
+    console.log(error);
+  }
+  loading.value = false;
+}
+
+async function getLiveRoomList() {
+  try {
+    loading.value = true;
+    const res = await fetchLiveRoomList({
+      is_show: SwitchEnum.yes,
+      orderName: 'updated_at',
+      orderBy: 'desc',
+      ...pageParams,
+    });
+    if (res.code === 200) {
+      const length = res.data.rows.length;
+      rankList.value = res.data.rows.map((item, index) => {
+        return {
+          users: [
+            {
+              id: item.users?.[0]?.id,
+              username: item.users?.[0]?.username,
+              avatar: item.users?.[0]?.avatar || '',
+            },
+          ],
+          live: item,
+          rank: index + 1,
+          level: 0,
+          score: 0,
+          balance: 0,
+          signin_nums: 0,
+        };
+      });
+      if (length < mockDataNums) {
+        rankList.value.push(...mockRank.slice(length));
+      }
+    }
+  } catch (error) {
+    console.log(error);
+  }
+  loading.value = false;
+}
+
+async function getUserList() {
+  try {
+    loading.value = true;
+    const res = await fetchUserList({
+      orderName: 'updated_at',
+      orderBy: 'desc',
+      ...pageParams,
+    });
+    if (res.code === 200) {
+      const length = res.data.rows.length;
+      rankList.value = res.data.rows.map((item, index) => {
+        return {
+          users: [
+            {
+              id: item.id,
+              username: item.username,
+              avatar: item.avatar || '',
+            },
+          ],
+          rank: index + 1,
+          level: 0,
+          score: 0,
+          balance: 0,
+          signin_nums: 0,
+        };
+      });
+      if (length < mockDataNums) {
+        rankList.value.push(...mockRank.slice(length));
+      }
+    }
+  } catch (error) {
+    console.log(error);
+  }
+  loading.value = false;
+}
+
+async function getSigninList() {
+  try {
+    loading.value = true;
+    const res = await fetchSigninList({
+      ...pageParams,
+      orderName: 'sum_nums,max_nums,recently_signin_time',
+      orderBy: 'desc,desc,desc',
+    });
+    if (res.code === 200) {
+      const length = res.data.rows.length;
+      rankList.value = res.data.rows.map((item, index) => {
+        return {
+          users: [
+            {
+              id: item.user?.id,
+              username: item.user?.username,
+              avatar: item.user?.avatar || '',
+            },
+          ],
+          rank: index + 1,
+          level: 0,
+          score: 0,
+          balance: 0,
+          signin_nums: item.sum_nums || 0,
+        };
+      });
+      if (length < mockDataNums) {
+        rankList.value.push(...mockRank.slice(length));
+      }
+    }
+  } catch (error) {
+    console.log(error);
+  }
+  loading.value = false;
+}
+
+function changeCurrRankType(type: RankTypeEnum) {
+  currRankType.value = type;
+  switch (type) {
+    case RankTypeEnum.liveRoom:
+      getLiveRoomList();
+      break;
+    case RankTypeEnum.user:
+      getUserList();
+      break;
+    case RankTypeEnum.signin:
+      getSigninList();
+      break;
+    case RankTypeEnum.wallet:
+      getWalletList();
+      break;
+    default:
+      break;
+  }
+}
+
+onMounted(() => {
+  changeCurrRankType(currRankType.value);
+});
+</script>
+
+<style lang="scss" scoped>
+.rank-wrap {
+  box-sizing: border-box;
+  padding-top: $header-height;
+  height: 100vh;
+  background-color: #f4f4f4;
+  .type-list {
+    display: flex;
+    align-items: center;
+    margin: 20px 0;
+    width: 100%;
+    .item {
+      flex: 1;
+      margin: 0 10px;
+      height: 40px;
+      border-radius: 10px;
+      background-color: $theme-color-gold;
+      color: white;
+      text-align: center;
+      font-weight: bold;
+      font-size: 20px;
+      line-height: 40px;
+      filter: grayscale(1);
+      cursor: pointer;
+
+      &.active {
+        filter: grayscale(0);
+      }
+    }
+  }
+  .rank-list {
+    width: 100%;
+
+    .living-tag {
+      display: inline-block;
+      margin: 0 auto;
+      padding: 2px 5px;
+      width: 40px;
+      border: 1px solid $theme-color-gold;
+      border-radius: 10px;
+      color: $theme-color-gold;
+      text-align: center;
+      font-size: 12px;
+      line-height: 1.2;
+      cursor: pointer;
+    }
+
+    .top {
+      display: flex;
+      align-items: flex-end;
+      justify-content: center;
+      margin-top: 100px;
+      width: 100%;
+      .item {
+        position: relative;
+        margin: 0 20px;
+        width: 200px;
+        height: 180px;
+        border-radius: 15px;
+        background-color: white;
+        text-align: center;
+
+        &.rank-1 {
+          height: 200px;
+          border-color: #ff6744;
+          color: #ff6744;
+          .rank {
+            margin-top: 20px;
+          }
+          .avatar-wrap {
+            .avatar {
+              border: 2px solid #ff6744;
+            }
+          }
+        }
+        &.rank-2 {
+          border-color: #44d6ff;
+          color: #44d6ff;
+          .avatar-wrap {
+            .avatar {
+              border: 2px solid #44d6ff;
+            }
+          }
+        }
+        &.rank-3 {
+          border-color: #ffb200;
+          color: #ffb200;
+          .avatar-wrap {
+            .avatar {
+              border: 2px solid #ffb200;
+            }
+          }
+        }
+
+        .avatar {
+          display: inline-block;
+          margin-top: -50px;
+          cursor: pointer;
+        }
+
+        .username {
+          margin-bottom: 10px;
+          font-size: 22px;
+        }
+
+        .rank {
+          position: relative;
+          display: inline-block;
+          padding: 0px 20px;
+          border: 1px solid;
+          border-radius: 20px;
+          font-size: 20px;
+          .living {
+            position: absolute;
+            bottom: 0;
+            left: 50%;
+            transform: translate(-50%, 130%);
+
+            @extend .living-tag;
+          }
+        }
+        .wallet,
+        .signin {
+          margin-top: 10px;
+        }
+      }
+    }
+    .top50-list {
+      margin-top: 20px;
+      border-radius: 10px;
+      background-color: white;
+      .top50-item {
+        display: flex;
+        align-items: center;
+        padding: 0 10px;
+        height: 40px;
+        color: #666;
+        &:nth-child(2n) {
+          background-color: #fafbfc;
+        }
+        .rank {
+          box-sizing: border-box;
+          margin-right: 20px;
+          width: 80px;
+          border-radius: 40px;
+          background-color: #84f9da;
+          color: white;
+          text-align: center;
+          font-size: 20px;
+        }
+        .left {
+          display: flex;
+          align-items: center;
+          font-size: 12px;
+          cursor: pointer;
+          .avatar {
+            width: 28px;
+            height: 28px;
+            border-radius: 50%;
+          }
+          .username {
+            margin-right: 15px;
+            margin-left: 10px;
+            max-width: 200px;
+
+            @extend %singleEllipsis;
+          }
+          .wallet,
+          .signin {
+            margin-left: 4px;
+          }
+        }
+      }
+    }
+  }
+  .null {
+    width: 100%;
+    text-align: center;
+  }
+}
+</style>

+ 328 - 0
src/views/shop/index.vue

@@ -0,0 +1,328 @@
+<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"
+      v-loading="loading"
+      :style="{ height: height + 'px' }"
+    >
+      <LongList
+        ref="longListRef"
+        class="goods-list"
+        :rootMargin="{
+          top: 0,
+          bottom: 0,
+          left: 0,
+          right: 0,
+        }"
+        :status="status"
+        @get-list-data="getListData"
+      >
+        <div
+          v-for="(item, index) in list"
+          :key="index"
+          class="goods"
+          @click="handleBuy(item)"
+        >
+          <div
+            v-lazy:background-image="item.cover"
+            class="top"
+          >
+            <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="18"
+              ></FloatTip>
+            </div>
+            <div class="price-wrap">
+              <span class="rmb">¥</span>
+              <span class="price">{{ formatMoney(item.price!, true) }}</span>
+              <span
+                v-if="item.original_price !== item.price"
+                class="original-price"
+              >
+                <del>¥{{ formatMoney(item.original_price!, true) }}</del>
+              </span>
+              <span class="pay-num">
+                {{ formatPayNum(item.pay_nums!) }}人付款
+              </span>
+            </div>
+          </div>
+        </div>
+      </LongList>
+    </div>
+    <Modal
+      v-if="showQrPay"
+      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, 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 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 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) {
+  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 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);
+}
+</script>
+
+<style lang="scss" scoped>
+.shop-wrap {
+  padding-top: $header-height;
+  padding-left: 30px;
+  padding-right: 30px;
+  height: 100vh;
+  .tab-list {
+    display: flex;
+    align-items: center;
+    margin-bottom: 20px;
+    height: 40px;
+    font-size: 14px;
+
+    user-select: none;
+    .tab {
+      position: relative;
+      margin-right: 20px;
+      cursor: pointer;
+      &.active {
+        color: $theme-color-gold;
+        font-size: 16px;
+
+        &::after {
+          position: absolute;
+          bottom: -6px;
+          left: 50%;
+          width: 20px;
+          height: 2px;
+          border-radius: 10px;
+          background-color: $theme-color-gold;
+          content: '';
+          transform: translateX(-50%);
+        }
+      }
+    }
+  }
+  .goods-list {
+    display: flex;
+    flex-wrap: wrap;
+    align-content: baseline;
+    justify-content: space-between;
+    .goods {
+      display: flex;
+      box-sizing: border-box;
+      margin-right: 10px;
+      margin-bottom: 15px;
+      padding: 18px 10px 10px;
+      width: 400px;
+      border-radius: 6px;
+      box-shadow: 0 4px 30px 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;
+        margin-right: 20px;
+        width: 100px;
+        height: 100px;
+        flex-shrink: 0;
+        @extend %containBg;
+        .badge {
+          position: absolute;
+          top: -10px;
+          right: -10px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          padding: 2px;
+          border-radius: 2px;
+          color: white;
+          .txt {
+            display: inline-block;
+            line-height: 1;
+            transform-origin: center !important;
+
+            @include minFont(10);
+          }
+        }
+      }
+      .bottom {
+        .title {
+          margin-top: 10px;
+          font-size: 22px;
+        }
+        .price-wrap {
+          display: flex;
+          align-items: baseline;
+          margin-top: 8px;
+          color: $theme-color-gold;
+          .rmb {
+            font-size: 16px;
+          }
+          .price {
+            font-weight: 500;
+            font-size: 28px;
+          }
+          .original-price {
+            color: #999;
+            font-size: 14px;
+          }
+          .pay-num {
+            margin-left: 5px;
+            color: #999;
+            font-size: 14px;
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 284 - 0
src/views/sponsors/index.vue

@@ -0,0 +1,284 @@
+<template>
+  <div class="sponsors-wrap">
+    <div class="content">
+      <h1 class="title">成为{{ PROJECT_NAME }}的赞助者</h1>
+      <div class="desc">
+        目前{{
+          PROJECT_NAME
+        }}仅仅是作者业余时间开发以及维护,需要投入非常多时间以及精力,
+        你的赞助将会为{{ PROJECT_NAME }}提供经济支持。
+      </div>
+      <div class="hr"></div>
+      <div class="list">
+        <div class="item">
+          <h2>以企业名义赞助{{ PROJECT_NAME }}</h2>
+          <p>
+            如果你是企业用户,并且从{{
+              PROJECT_NAME
+            }}中受益,请考虑捐赠以示感谢。
+          </p>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>以个人名义赞助{{ PROJECT_NAME }}</h2>
+          <p>
+            如果你是个人用户,并且从{{
+              PROJECT_NAME
+            }}中受益,请考虑捐赠以示感谢——就当是偶尔请我们喝杯咖啡。
+          </p>
+        </div>
+        <div class="hr"></div>
+        <div class="item">
+          <h2>赞助等级</h2>
+          <ul>
+            <li>铂金赞助商(2500元/月)</li>
+            <li>金牌赞助商(1500元/月)</li>
+            <li>银牌赞助商(1000元/月)</li>
+            <li>铜牌赞助商(500元/月)</li>
+            <li>慷慨支持者(100元/月)</li>
+            <li>个人支持者(10元/月)</li>
+          </ul>
+        </div>
+        <div class="hr"></div>
+        <div class="item sponsors">
+          <h2>当前赞助商</h2>
+          <h3>铂金赞助商</h3>
+          <div class="hr"></div>
+          <div class="list platinum-list">
+            <a
+              v-for="(item, index) in platinumList"
+              :key="index"
+              class="bg platinum"
+              :href="item.url"
+              @click.prevent="openToTarget(item.url)"
+            >
+              <img
+                v-lazy="item.logo"
+                alt=""
+              />
+            </a>
+          </div>
+          <h3>金牌赞助商</h3>
+          <div class="hr"></div>
+          <div class="list gold-list">
+            <a
+              v-for="(item, index) in goldList"
+              :key="index"
+              class="bg gold"
+              :href="item.url"
+              @click.prevent="openToTarget(item.url)"
+            >
+              <img
+                v-lazy="item.logo"
+                alt=""
+              />
+            </a>
+          </div>
+          <h3>银牌赞助商</h3>
+          <div class="hr"></div>
+          <div class="list silver-list">
+            <a
+              v-for="(item, index) in silverList"
+              :key="index"
+              class="bg silver"
+              :href="item.url"
+              @click.prevent="openToTarget(item.url)"
+            >
+              <img
+                v-lazy="item.logo"
+                alt=""
+              />
+            </a>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="aside">
+      <div class="title">本页目录</div>
+      <div class="item">以个人名义赞助{{ PROJECT_NAME }}</div>
+      <div class="item">以企业名义赞助{{ PROJECT_NAME }}</div>
+      <div class="item">赞助等级</div>
+      <div class="item">当前赞助商</div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { openToTarget } from 'billd-utils';
+import { ref } from 'vue';
+
+import { PROJECT_NAME } from '@/constant';
+
+// 铂金赞助
+const platinumList = ref([
+  {
+    logo: 'https://sponsors.vuejs.org/images/line_corporation.avif',
+    url: 'aaa',
+  },
+  {
+    logo: 'https://sponsors.vuejs.org/images/vueschool.avif',
+    url: 'bbb',
+  },
+]);
+
+// 金牌赞助
+const goldList = ref([
+  {
+    logo: 'https://sponsors.vuejs.org/images/famous_fonts.avif',
+    url: '',
+  },
+  {
+    logo: 'https://sponsors.vuejs.org/images/herodevs.avif',
+    url: '',
+  },
+  {
+    logo: 'https://sponsors.vuejs.org/images/herodevs.avif',
+    url: '',
+  },
+]);
+
+// 银牌赞助
+const silverList = ref([
+  {
+    logo: 'https://sponsors.vuejs.org/images/vuemastery.avif',
+    url: '',
+  },
+  {
+    logo: 'https://sponsors.vuejs.org/images/herodevs.avif',
+    url: '',
+  },
+  {
+    logo: 'https://sponsors.vuejs.org/images/herodevs.avif',
+    url: '',
+  },
+]);
+</script>
+
+<style lang="scss" scoped>
+.sponsors-wrap {
+  display: flex;
+  box-sizing: border-box;
+  margin: 0 auto;
+  padding-top: calc($header-height + 10px);
+  width: 960px;
+  color: rgb(33, 53, 71);
+
+  .content {
+    flex: 1;
+    font-size: 16px;
+    .title {
+      margin: 0;
+      margin-bottom: 60px;
+      font-weight: 500;
+      font-size: 40px;
+    }
+    .hr {
+      margin: 60px 0 20px 0;
+      width: 100%;
+      height: 1px;
+      background-color: #e7e7e7;
+    }
+
+    .link {
+      color: $theme-color-gold;
+      text-decoration: none;
+      font-weight: 500;
+    }
+    .list {
+      h2 {
+        font-weight: 600;
+      }
+
+      .item {
+        font-size: 16px;
+        &.sponsors {
+          h3 {
+            margin-top: 50px;
+            text-align: center;
+          }
+          .list {
+            display: flex;
+            flex-wrap: wrap;
+            justify-content: space-evenly;
+          }
+          .bg {
+            display: flex;
+            align-items: center;
+            flex-shrink: 0;
+            justify-content: center;
+            box-sizing: border-box;
+            margin-bottom: 5px;
+            border-radius: 4px;
+            background-color: #f9f9f9;
+            cursor: pointer;
+            &.platinum {
+              width: 100%;
+              height: 160px;
+              img {
+                width: 300px;
+              }
+            }
+            &.gold {
+              width: 49%;
+              height: 120px;
+              img {
+                width: 200px;
+              }
+              // 最后一行是2个元素,则将最后一个元素添加一个30%
+              &:last-child:nth-child(2n-1) {
+                margin-right: calc(((100% - (49% * 2)) * 1 / 2) + (49% * 1));
+              }
+              // 最后一行是1个元素
+              &:last-child:nth-child(2n-2) {
+                margin-right: calc(((100% - (49% * 2)) * 2 / 2) + (49% * 2));
+              }
+            }
+            &.silver {
+              width: 32%;
+              height: 90px;
+              img {
+                width: 150px;
+              }
+              // 最后一行是3个元素,则将最后一行3个元素都添加底部外边距
+              &:last-child:nth-child(3n) {
+              }
+              // 最后一行是2个元素,则将最后一个元素添加一个30%
+              &:last-child:nth-child(3n-1) {
+                margin-right: calc(((100% - (32% * 3)) * 1 / 4) + (32% * 1));
+              }
+              // 最后一行是1个元素
+              &:last-child:nth-child(3n-2) {
+                margin-right: calc(((100% - (32% * 3)) * 2 / 4) + (32% * 2));
+              }
+            }
+            img {
+              max-width: 100%;
+              max-height: 100%;
+            }
+          }
+        }
+      }
+    }
+  }
+  .aside {
+    padding-left: 90px;
+    width: 150px;
+
+    .title {
+      margin-bottom: 8px;
+      color: rgb(33, 53, 71);
+      font-weight: 700;
+      font-size: 12px;
+    }
+    .item {
+      margin-bottom: 8px;
+      color: rgba(60, 60, 60, 0.7);
+      font-size: 13px;
+      cursor: pointer;
+      &:hover {
+        color: #213547;
+      }
+    }
+  }
+}
+</style>

+ 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>

+ 140 - 0
src/views/user/index.vue

@@ -0,0 +1,140 @@
+<template>
+  <div
+    v-loading="getUserLoading"
+    class="profile-wrap"
+  >
+    <div class="uid">用户id:{{ userInfo?.id }}</div>
+    <div class="avatar">
+      <span class="txt">用户头像:</span>
+      <Avatar
+        :url="userInfo?.avatar"
+        :name="userInfo?.username"
+        :size="30"
+      ></Avatar>
+    </div>
+    <div>用户昵称:{{ userInfo?.username }}</div>
+    <div>
+      用户角色:{{ userInfo?.roles?.map((item) => item.role_name).join(',') }}
+    </div>
+    <div>注册时间:{{ userInfo?.created_at }}</div>
+    <br />
+    <div class="pull-url">
+      <span
+        v-if="
+          !userInfo?.live_rooms?.length &&
+          userStore.userInfo?.id === userInfo?.id
+        "
+        class="link"
+        @click="openLiveRoom"
+      >
+        未开通
+      </span>
+      <span v-else-if="!userInfo?.live_rooms?.length">
+        该用户未开通直播间
+      </span>
+      <div v-else>
+        <div>
+          直播间地址:
+          <a
+            :href="getLiveRoomPageUrl(userInfo?.live_rooms?.[0]?.id!)"
+            class="link"
+            target="_blank"
+          >
+            {{ getLiveRoomPageUrl(userInfo?.live_rooms?.[0]?.id!) }}
+          </a>
+        </div>
+        <div>直播间名称:{{ userInfo?.live_rooms?.[0]?.name }}</div>
+        <div>
+          直播间简介:{{ userInfo?.live_rooms?.[0]?.desc || '暂无简介' }}
+        </div>
+        <div>
+          直播间分区:{{
+            userInfo.live_rooms[0]?.areas?.[0]?.name || '暂无分区'
+          }}
+        </div>
+        <div>开通时间:{{ userInfo?.live_rooms?.[0]?.created_at }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { openToTarget } from 'billd-utils';
+import { ref, watchEffect } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+import { fetchFindUser } from '@/api/user';
+import { URL_QUERY } from '@/constant';
+import { loginTip } from '@/hooks/use-login';
+import { routerName } from '@/router';
+import { useUserStore } from '@/store/user';
+import { LiveRoomTypeEnum } from '@/types/ILiveRoom';
+import { IUser } from '@/types/IUser';
+import { getLiveRoomPageUrl } from '@/utils';
+
+const userStore = useUserStore();
+const route = useRoute();
+const router = useRouter();
+
+const userId = ref(-1);
+const userInfo = ref<IUser>();
+const getUserLoading = ref(false);
+
+watchEffect(() => {
+  if (route.params.id) {
+    userId.value = Number(route.params.id as string);
+    handleUserInfo();
+  }
+});
+
+async function handleUserInfo() {
+  try {
+    getUserLoading.value = true;
+    const res = await fetchFindUser(userId.value);
+    if (res.code === 200) {
+      userInfo.value = res.data;
+    }
+  } catch (error) {
+    console.error(error);
+  } finally {
+    getUserLoading.value = false;
+  }
+}
+
+function openLiveRoom() {
+  if (!loginTip()) {
+    return;
+  }
+  const url = router.resolve({
+    name: routerName.push,
+    query: { [URL_QUERY.liveType]: LiveRoomTypeEnum.srs },
+  });
+  openToTarget(url.href);
+}
+</script>
+
+<style lang="scss" scoped>
+.profile-wrap {
+  position: relative;
+  padding: 10px;
+  .link {
+    color: $theme-color-gold;
+    text-decoration: none;
+    cursor: pointer;
+  }
+  .avatar {
+    display: flex;
+    align-items: center;
+    .txt {
+      margin-right: 10px;
+    }
+  }
+  .url-wrap {
+    position: relative;
+    margin-top: 10px;
+    .cdn {
+      margin-bottom: 10px;
+    }
+  }
+}
+</style>

+ 215 - 0
src/views/videoTools/frameScreenshot/canvas/index.vue

@@ -0,0 +1,215 @@
+<template>
+  <div class="wrap">
+    <h1>视频帧截图(canvas)</h1>
+    <n-button
+      :loading="loading"
+      type="primary"
+      @click.stop="handleVideoFrame"
+    >
+      选择视频
+      <input
+        ref="uploadRef"
+        type="file"
+        class="input-upload"
+        @change="uploadChange"
+      />
+    </n-button>
+    <span>
+      进度:{{
+        currentDuation ? ((currentDuation / videoDuration) * 100).toFixed() : 0
+      }}%
+    </span>
+    <n-button
+      v-if="currentDuation && currentDuation - videoDuration === 0"
+      type="success"
+      @click="handleDownload"
+    >
+      下载
+    </n-button>
+    <div
+      ref="listRef"
+      class="frame-list"
+      :style="{ height: height + 'px' }"
+    >
+      <div
+        v-for="(item, index) in imgList"
+        :key="index"
+        class="item"
+      >
+        <img ref="imgListRef" />
+        <div class="time">{{ item }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import JSZip from 'jszip';
+import { nextTick, onMounted, ref } from 'vue';
+
+import { createVideo, formatDownTime2, generateBase64 } from '@/utils';
+
+const uploadRef = ref<HTMLInputElement>();
+const loading = ref(false);
+const currentDuation = ref(0);
+const videoDuration = ref(0);
+const height = ref(0);
+const fileList = ref<{ name: string; data: string }[]>([]);
+const imgList = ref<any[]>([]);
+const imgListRef = ref<HTMLImageElement[]>([]);
+const listRef = ref<HTMLDivElement>();
+
+function handleDownload() {
+  // 初始化一个zip打包对象
+  const zip = new JSZip();
+  // 创建一个被用来打包的名为Hello.txt的文件
+  fileList.value.forEach((file) => {
+    zip.file(file.name, file.data, { base64: true });
+  });
+  // 把打包内容异步转成blob二进制格式
+  zip.generateAsync({ type: 'blob' }).then(function (content) {
+    // 创建隐藏的可下载链接
+    const eleLink = document.createElement('a');
+    eleLink.download = '视频帧截图.zip';
+    eleLink.style.display = 'none';
+    // 下载内容转变成blob地址
+    eleLink.href = URL.createObjectURL(content);
+    // 触发点击
+    document.body.appendChild(eleLink);
+    eleLink.click();
+    // 然后移除
+    document.body.removeChild(eleLink);
+  });
+}
+
+function uploadChange() {
+  if (loading.value) return;
+  loading.value = true;
+  imgList.value = [];
+  currentDuation.value = 0;
+  videoDuration.value = 0;
+  nextTick(() => {
+    const file = uploadRef.value?.files?.[0];
+
+    if (!file) return;
+    const url = URL.createObjectURL(file);
+    const videoEl = createVideo({
+      appendChild: false,
+    });
+    videoEl.src = url;
+
+    let currentTime = 0;
+
+    function captureFrame() {
+      const res = formatDownTime2({
+        startTime: +new Date(),
+        endTime: +new Date() + (currentTime + 1) * 1000,
+        addZero: true,
+      });
+      let time = '';
+      if (res.d) {
+        time = `${res.d}天${res.h}:${res.m}:${res.s}`;
+      } else {
+        time = `${res.h}:${res.m}:${res.s}`;
+      }
+      imgList.value.push(time);
+      nextTick(() => {
+        // 确保视频已足够加载以获取当前帧
+        const img = imgListRef.value[imgListRef.value.length - 1];
+        if (img) {
+          const str = generateBase64(videoEl);
+          img.src = str;
+          fileList.value.push({
+            name: `${currentTime}.webp`,
+            data: str.split(';base64,')[1],
+          });
+          currentDuation.value = currentDuation.value + 1;
+          if (videoDuration.value > currentTime) {
+            // 移动到下一帧
+            videoEl.currentTime += 1;
+            currentTime += 1;
+          }
+        }
+      });
+    }
+
+    videoEl.onloadeddata = () => {
+      currentTime = videoEl.currentTime;
+      videoDuration.value = Math.ceil(videoEl.duration);
+      captureFrame();
+    };
+
+    videoEl.onseeked = () => {
+      if (currentTime < videoDuration.value) {
+        captureFrame();
+      } else {
+        loading.value = false;
+        if (uploadRef.value) {
+          uploadRef.value.value = '';
+        }
+      }
+    };
+  });
+}
+
+function handleVideoFrame() {
+  uploadRef.value?.click();
+}
+
+function getHeight() {
+  const h =
+    document.documentElement.clientHeight -
+    (listRef.value?.getBoundingClientRect().top || 0);
+  height.value = h;
+}
+
+onMounted(() => {
+  getHeight();
+});
+</script>
+
+<style lang="scss" scoped>
+.wrap {
+  padding-top: 10px;
+  padding-left: 30px;
+  .input-upload {
+    width: 0;
+    height: 0;
+    opacity: 0;
+  }
+  .frame-list {
+    display: flex;
+    overflow: scroll;
+    align-content: baseline;
+    flex-wrap: wrap;
+    margin-top: 10px;
+
+    @extend %customScrollbar;
+
+    .item {
+      position: relative;
+      margin-right: 10px;
+      margin-bottom: 10px;
+      padding: 3px;
+      width: 200px;
+      height: fit-content;
+      border: 1px solid black;
+      border-radius: 5px;
+      .time {
+        position: absolute;
+        right: 3px;
+        bottom: 3px;
+        padding: 3px 4px;
+        border-radius: 3px;
+        background-color: rgba($color: #000000, $alpha: 0.5);
+        color: white;
+        font-size: 13px;
+      }
+      img {
+        width: 100%;
+        height: 100%;
+      }
+    }
+  }
+}
+</style>

+ 7 - 0
src/views/videoTools/frameScreenshot/webcodec/index.vue

@@ -0,0 +1,7 @@
+<template>
+  <div></div>
+</template>
+
+<script lang="ts" setup></script>
+
+<style lang="scss" scoped></style>

+ 48 - 0
src/views/videoTools/index.vue

@@ -0,0 +1,48 @@
+<template>
+  <div class="list">
+    <div
+      class="item"
+      @click.prevent="router.push({ name: routerName.frameScreenshotByCanvas })"
+    >
+      视频帧截图(canvas)
+    </div>
+    <div
+      class="item"
+      @click.prevent="
+        router.push({ name: routerName.frameScreenshotByWebcodec })
+      "
+    >
+      视频帧截图(webcodec)
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { useRouter } from 'vue-router';
+
+import { routerName } from '@/router';
+
+const router = useRouter();
+</script>
+
+<style lang="scss" scoped>
+.list {
+  display: flex;
+  align-items: center;
+  padding-top: 10px;
+  padding-left: 30px;
+  .item {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 15px;
+    margin-bottom: 15px;
+    width: 200px;
+    height: 100px;
+    border-radius: 10px;
+    background-color: rgba($color: $theme-color-gold, $alpha: 0.8);
+    font-size: 18px;
+    cursor: pointer;
+  }
+}
+</style>

+ 164 - 0
src/views/wallet/index.vue

@@ -0,0 +1,164 @@
+<template>
+  <div class="order-wrap">
+    <h2 class="title">
+      我的钱包:<span class="val">{{
+        formatMoney(userStore.userInfo?.wallet?.balance!)
+      }}</span>
+      元
+    </h2>
+    <div class="list">
+      <div class="head-wrap">
+        <div
+          v-for="(item, index) in headList"
+          :key="index"
+          class="head"
+        >
+          <div>{{ item.label }}</div>
+        </div>
+      </div>
+      <div
+        v-for="(item, index) in walletRecordList"
+        :key="index"
+        class="item"
+      >
+        <div>{{ item.created_at }}</div>
+        <div>{{ item.order_id || '无' }}</div>
+        <div>{{ typeMap[item.type!] }}</div>
+        <div>{{ item.name }}</div>
+        <div>
+          <span>{{
+            item.amount_status === WalletRecordAmountStatusEnum.add ? '+' : '-'
+          }}</span>
+          <span>{{ formatMoney(item.amount!) }}元</span>
+        </div>
+      </div>
+      <div v-if="!walletRecordList.length">{{ t('common.nonedata') }}</div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+import { fetchMyWallet } from '@/api/wallet';
+import { fetchWalletRecordMyList } from '@/api/walletRecord';
+import { fullLoading } from '@/components/FullLoading';
+import { loginTip } from '@/hooks/use-login';
+import {
+  IWalletRecord,
+  WalletRecordAmountStatusEnum,
+  WalletRecordEnum,
+} from '@/interface';
+import { useUserStore } from '@/store/user';
+import { formatMoney } from '@/utils';
+
+const userStore = useUserStore();
+const walletRecordList = ref<IWalletRecord[]>([]);
+const { t } = useI18n();
+
+const headList = ref([
+  {
+    label: '创建时间',
+    key: 'created_at',
+  },
+  {
+    label: '订单id',
+    key: 'order_id',
+  },
+  {
+    label: '类型',
+    key: 'type',
+  },
+  {
+    label: '名称',
+    key: 'name',
+  },
+  {
+    label: '金额',
+    key: 'amount',
+  },
+]);
+
+const typeMap = {
+  [WalletRecordEnum.recharge]: '充值',
+  [WalletRecordEnum.reward]: '打赏',
+  [WalletRecordEnum.signin]: '签到',
+};
+
+onMounted(() => {
+  if (!loginTip()) {
+    return;
+  }
+  updateMyWallet();
+  getPayList();
+});
+
+async function updateMyWallet() {
+  const res = await fetchMyWallet({});
+  if (res.code === 200) {
+    if (userStore.userInfo?.wallet?.balance !== undefined) {
+      userStore.userInfo.wallet.balance = res.data.balance;
+    }
+  }
+}
+
+async function getPayList() {
+  try {
+    fullLoading({ loading: true });
+    const res = await fetchWalletRecordMyList({
+      orderName: 'created_at',
+      orderBy: 'desc',
+    });
+    if (res.code === 200) {
+      walletRecordList.value = res.data.rows;
+    }
+  } catch (error) {
+    console.log(error);
+  } finally {
+    fullLoading({ loading: false });
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.order-wrap {
+  padding-top: calc($header-height + 10px);
+  padding-left: 20px;
+  padding-right: 20px;
+  .title {
+    text-align: center;
+    .val {
+      color: $theme-color-gold;
+    }
+  }
+  .list {
+    text-align: center;
+
+    .head-wrap {
+      display: flex;
+      align-items: center;
+      .head {
+        flex: 1;
+        box-sizing: border-box;
+        margin-bottom: 5px;
+      }
+    }
+    .item {
+      display: flex;
+      align-items: center;
+      height: 40px;
+      color: #666;
+      > div {
+        display: flex;
+        align-items: center;
+        flex: 1;
+        justify-content: center;
+      }
+      &:nth-child(2n) {
+        background-color: #fafbfc;
+      }
+    }
+  }
+}
+</style>