feat: 开发消息功能 (#11)

* feat: back title

* feat: 本地联调

* feat: 聊天室初始化,以及联动query

* feat: useSocket,online users count

* feat: 调整useSocket、useCreateRoom(优化hook)

* feat: 拆分roomInfo、roomConnect 的 hook

* feat: chat room 基本布局

* feat: chat

* fix: 样式 + emoji

* feat: 拖拽改变元素大小的hook,一些样式fix

* feat: video chat init

* feat: 调试摄像头、audio信息

* feat: video的一些设置,代码优化

* fix: 样式

* feat: 视频聊天的连接测试

* feat: https

* feat: 多人视频通话,待优化整理代码

* feat: 代码优化、边界处理

* feat: 优化一轮使用体验

* feat: 构建相关
This commit is contained in:
Change
2023-09-07 17:50:17 +08:00
committed by GitHub
parent 40d7619460
commit a5ae9d98e7
77 changed files with 3483 additions and 54 deletions

2
.gitignore vendored
View File

@@ -53,3 +53,5 @@ package-lock.json
docker/mysql/data/*
client_dist

2
client/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -4,7 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
"dev:web": "pnpm -C packages/rtc-web dev"
"dev:web": "pnpm -C packages/rtc-web dev",
"build:web": "pnpm -C packages/rtc-web build"
},
"keywords": [],
"author": "",

View File

@@ -18,10 +18,11 @@ module.exports = {
},
plugins: ['vue', '@typescript-eslint', 'prettier'],
rules: {
indent: ['error', 2],
indent: ['error', 2, { offsetTernaryExpressions: true }],
semi: ['error', 'always'],
'vue/attribute-hyphenation': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'no-debugger': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
},
};

View File

@@ -11,10 +11,15 @@
"@types/lodash": "^4.14.195",
"@vitejs/plugin-vue-jsx": "^3.0.1",
"@vueuse/core": "^10.2.0",
"@vueuse/router": "^10.2.1",
"dayjs": "^1.11.9",
"lodash": "^4.17.21",
"nanoid": "^4.0.2",
"socket.io-client": "2.3.0",
"vue": "^3.3.4",
"vue-router": "4"
"vue-router": "4",
"vue3-emoji-picker": "^1.1.7",
"vue3-popper": "^1.5.0"
},
"devDependencies": {
"@types/node": "^20.3.1",
@@ -32,7 +37,9 @@
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"tailwindcss": "^3.3.2",
"terser": "^5.19.4",
"typescript": "^5.1.3",
"vconsole": "^3.15.1",
"vite": "^4.3.9",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-svg-icons": "^2.0.1",

View File

@@ -1,8 +1,20 @@
<script lang="ts" setup>
import Layout from '@/layout/index.vue';
import ErrorBoundary from '@/components/error-boundary/index.vue';
import { useErrorCaptured } from '@/hooks';
const errors = useErrorCaptured();
</script>
<!-- 这里后面可以加 provider区分登录和未登录页面 -->
<template>
<Layout />
<Suspense>
<Layout />
<template v-if="errors.length" #fallback>
<ErrorBoundary
class="h-screen"
:tips-list="['服务器开小差了,请联系管理员']"
/>
</template>
</Suspense>
</template>

View File

@@ -1 +1,21 @@
@import './tailwindcss';
/* width */
::-webkit-scrollbar {
width: 4px;
}
/* Track */
::-webkit-scrollbar-track {
background: #f1f1f1;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: #888;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #555;
}

View File

@@ -0,0 +1 @@
<svg t="1691553735044" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1412" width="200" height="200"><path d="M483.363 517.9h-53.357v53.348h53.357V517.9z m-160.051 0v53.348h53.348V517.9h-53.348z m213.398 53.348h53.348V517.9H536.71v53.348z m160.051 0V517.9h-53.347v53.348h53.347z m-266.755 53.365v53.348h53.357v-53.348h-53.357z m-106.694 0l19.802 53.348h33.545v-53.348h-53.347z m213.398 53.348h53.348v-53.348H536.71v53.348z m-106.704 69.748l53.357 10.273v-26.674h-53.357v16.401z m106.704 5.514l53.348-8.576v-13.338H536.71v21.914z m106.704-75.262h38.922l14.425-53.348h-53.347v53.348zM536.71 464.553h-53.347V517.9h53.347v-53.347z m-106.704 0h-53.347V517.9h53.347v-53.347zM590.058 517.9h53.356v-53.347h-53.356V517.9zM483.363 624.613h53.347v-53.365h-53.347v53.365z m-53.357-53.365h-53.347v53.365h53.347v-53.365z m160.052 53.365h53.356v-53.365h-53.356v53.365z m-106.695 53.348v53.348h53.347v-53.348h-53.347z m-106.704 0v36.746l53.347 16.602v-53.348h-53.347z m213.399 53.348l53.356-17.449v-35.898h-53.356v53.347z m106.703-160.061v53.365l17.414-35.896v-17.469h-17.414zM536.71 464.553h53.348v-53.348H536.71v53.348z m160.051 0v-53.348h-53.347v53.348h53.347z m-213.398-53.347h-53.357v53.348h53.357v-53.348z m-160.051 0v53.348h53.348v-53.348h-53.348z m106.694-53.348h-53.347v53.348h53.347v-53.348z m106.704 0h-53.347v53.348h53.347v-53.348z m53.348 53.348h53.356v-53.348h-53.356v53.348z m-53.348-53.348h53.348v-53.347H536.71v53.347z m160.051 0v-53.347h-53.347v53.347h53.347z m-213.398-53.347h-53.357v53.347h53.357v-53.347z m-160.051 0v53.347h53.348v-53.347h-53.348z m106.694-53.347h-53.347v53.347h53.347v-53.347z m106.704 0h-53.347v53.347h53.347v-53.347z m53.348 53.347h53.356v-53.347h-53.356v53.347z m-53.348-53.347h53.348v-53.366H536.71v53.366z m160.051 0v-53.366h-53.347v53.366h53.347z m-213.398-53.367h-53.357v53.366h53.357v-53.366z m-160.051 0v53.366h53.348v-53.366h-53.348z m106.694-53.347h-53.347v53.347h53.347V144.45z m106.704 53.347V144.45h-53.347v53.347h53.347z m53.348 0h53.356V144.45h-53.356v53.347z m0-53.347V91.103H536.71v53.347h53.348z m106.703 0l-53.347-53.347v53.347h53.347z m-213.398 0V91.103h-53.357v53.347h53.357zM363.58 104.846l-40.269 39.604h53.348V91.103l-13.079 13.743z m173.13-67.091h-53.347v53.348h53.347V37.755zM430.006 64.429l-26.674 13.337-26.673 13.337h53.347V64.429zM624.092 78.08l-34.034-13.65v26.674h53.356L624.092 78.08z" fill="" p-id="1413"></path><path d="M847.257 554.738c0-16.934-5.533-27.965-15.551-27.965-10.938 0-15.568 11.031-15.568 27.965 0 162.92-169.403 303.777-306.166 303.777-155.836 0-305.77-152-305.77-304.184 0-16.934-9.233-21.527-15.098-21.527-6.42 0-16.427 5-16.427 21.934 0 175.906 151.252 320.232 323.128 335.746v72.512h-169.81c-16.952 0-30.667 5.609-30.667 18.281 0 11.824 13.715 15.365 30.667 15.365h367.953c16.953 0 30.667-2.951 30.667-15.346 0-13.283-13.714-18.301-30.667-18.301H526.499v-72.512c171.877-15.512 320.758-159.838 320.758-335.745zM509.972 769.383c118.547 0 214.644-96.125 214.644-214.645V248.12c0-118.537-96.097-214.644-214.644-214.644-118.546 0-214.644 96.106-214.644 214.644v306.619c0 118.519 96.097 214.644 214.644 214.644zM328.163 248.12c0-101.197 75.852-183.248 181.809-183.248 97.397 3.247 180.73 75.262 183.387 183.248v306.619c0 97.971-73.104 185.332-183.387 185.332-110.394 0-181.809-85.408-181.809-185.332V248.12z" p-id="1414"></path></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1 @@
<svg t="1689645854295" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1042" width="200" height="200"><path d="M947.4 864C893.2 697.7 736.2 578.9 551 575.5c-23.1-0.4-44.9 0.1-65.6 1.5v164.3c0.1 0.5 0.2 1 0.2 1.5 0 4-3.3 7.3-7.3 7.3-2.7 0-5-1.4-6.2-3.5v0.7L68.8 465.4h2.1c-4 0-7.3-3.3-7.3-7.3 0-2.9 1.7-5.4 4.1-6.6L472 169v0.7c1.3-2.1 3.6-3.5 6.2-3.5 4 0 7.3 3.3 7.3 7.3 0 0.5-0.1 1-0.2 1.5v159.4c18.5-0.9 37.9-1.2 58.3-0.8 230.1 3.9 416.7 196.9 416.7 427.1 0.1 35.5-4.5 70.2-12.9 103.3z m-462-704.4v0.2h-0.4l0.4-0.2z m0 596.9l-0.3-0.2h0.3v0.2z" fill="#2F54EB" p-id="1043"></path></svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1 @@
<svg t="1691553222480" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1258" width="200" height="200"><path d="M704 768V256H128v512h576z m64-416L960 256v512l-192-96v128a31.168 31.168 0 0 1-8.96 23.04 31.168 31.168 0 0 1-23.04 8.96h-640a31.168 31.168 0 0 1-23.04-8.96 31.168 31.168 0 0 1-8.96-23.04v-576c0-9.344 3.008-17.024 8.96-23.04A31.168 31.168 0 0 1 96 192h640c9.344 0 17.024 3.008 23.04 8.96 5.952 6.016 8.96 13.696 8.96 23.04v128z m0 72v176l128 64v-304l-128 64zM192 320h192v64H192V320z" p-id="1259"></path></svg>

After

Width:  |  Height:  |  Size: 563 B

View File

@@ -0,0 +1 @@
<svg t="1689646256077" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2749" width="200" height="200"><path d="M512 64c259.2 0 469.333333 200.576 469.333333 448s-210.133333 448-469.333333 448a484.48 484.48 0 0 1-232.725333-58.88l-116.394667 50.645333a42.666667 42.666667 0 0 1-58.517333-49.002666l29.76-125.013334C76.629333 703.402667 42.666667 611.477333 42.666667 512 42.666667 264.576 252.8 64 512 64z m0 64C287.488 128 106.666667 300.586667 106.666667 512c0 79.573333 25.557333 155.434667 72.554666 219.285333l5.525334 7.317334 18.709333 24.192-26.965333 113.237333 105.984-46.08 27.477333 15.018667C370.858667 878.229333 439.978667 896 512 896c224.512 0 405.333333-172.586667 405.333333-384S736.512 128 512 128z m-157.696 341.333333a42.666667 42.666667 0 1 1 0 85.333334 42.666667 42.666667 0 0 1 0-85.333334z m159.018667 0a42.666667 42.666667 0 1 1 0 85.333334 42.666667 42.666667 0 0 1 0-85.333334z m158.997333 0a42.666667 42.666667 0 1 1 0 85.333334 42.666667 42.666667 0 0 1 0-85.333334z" fill="#2F54EB" p-id="2750"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg t="1689914473474" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1272" width="200" height="200"><path d="M514.048 128q79.872 0 149.504 30.208t121.856 82.432 82.432 122.368 30.208 150.016q0 78.848-30.208 148.48t-82.432 121.856-121.856 82.432-149.504 30.208-149.504-30.208-121.856-82.432-82.432-121.856-30.208-148.48q0-79.872 30.208-150.016t82.432-122.368 121.856-82.432 149.504-30.208z" p-id="1273" fill="#0CC6BF"></path></svg>

After

Width:  |  Height:  |  Size: 476 B

View File

@@ -0,0 +1 @@
<svg t="1689752584815" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6347" width="200" height="200"><path d="M480 452.266667m-426.666667 0a426.666667 426.666667 0 1 0 853.333334 0 426.666667 426.666667 0 1 0-853.333334 0Z" fill="#FFE500" p-id="6348"></path><path d="M480 25.6c-234.666667 0-426.666667 192-426.666667 426.666667s192 426.666667 426.666667 426.666666 426.666667-192 426.666667-426.666666-192-426.666667-426.666667-426.666667z m0 789.333333C264.533333 814.933333 91.733333 640 91.733333 426.666667c0-215.466667 174.933333-388.266667 388.266667-388.266667S870.4 211.2 870.4 426.666667c0 213.333333-174.933333 388.266667-390.4 388.266666z" fill="#EBCB00" p-id="6349"></path><path d="M352 110.933333a128 32 0 1 0 256 0 128 32 0 1 0-256 0Z" fill="#FFF48C" p-id="6350"></path><path d="M138.666667 966.4a341.333333 32 0 1 0 682.666666 0 341.333333 32 0 1 0-682.666666 0Z" fill="#45413C" opacity=".15" p-id="6351"></path><path d="M170.666667 558.933333a53.333333 32 0 1 0 106.666666 0 53.333333 32 0 1 0-106.666666 0Z" fill="#FFAA54" p-id="6352"></path><path d="M277.333333 409.6m-21.333333 0a21.333333 21.333333 0 1 0 42.666667 0 21.333333 21.333333 0 1 0-42.666667 0Z" fill="#45413C" p-id="6353"></path><path d="M693.333333 409.6m-21.333333 0a21.333333 21.333333 0 1 0 42.666667 0 21.333333 21.333333 0 1 0-42.666667 0Z" fill="#45413C" p-id="6354"></path><path d="M384 729.6s51.2-21.333333 96-21.333333 96 21.333333 96 21.333333M512 526.933333h416c23.466667 0 42.666667 19.2 42.666667 42.666667s-19.2 42.666667-42.666667 42.666667H512" fill="#FFE500" p-id="6355"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<svg t="1691555214679" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2779" width="200" height="200"><path d="M512 1023.999069a508.779681 508.779681 0 0 1-199.291759-40.238472 510.300163 510.300163 0 0 1-162.745886-109.73071A510.354466 510.354466 0 0 1 40.231646 711.284001 508.826226 508.826226 0 0 1 0.000931 512a508.841741 508.841741 0 0 1 40.238472-199.291759 510.338951 510.338951 0 0 1 109.73071-162.745886A510.292406 510.292406 0 0 1 312.708241 40.239403 508.787439 508.787439 0 0 1 512 0.000931a508.795196 508.795196 0 0 1 199.291759 40.238472 510.222587 510.222587 0 0 1 162.745886 109.722952 510.284648 510.284648 0 0 1 109.730709 162.745886A508.841741 508.841741 0 0 1 1023.999069 512a508.826226 508.826226 0 0 1-40.238472 199.291759 510.292406 510.292406 0 0 1-109.73071 162.745886 510.27689 510.27689 0 0 1-162.745886 109.730709A508.795196 508.795196 0 0 1 512 1023.999069zM252.695744 543.348307a13.4904 13.4904 0 0 0 3.878781 9.471982 13.335248 13.335248 0 0 0 9.471983 3.917569l127.456738-0.201697a13.428339 13.428339 0 0 0 13.412824-13.412824l0.06206-51.680875a87.272569 87.272569 0 0 1 24.420804-20.030025 161.489161 161.489161 0 0 1 82.493911-20.16966h0.279272a159.573043 159.573043 0 0 1 82.230154 20.084327 86.985539 86.985539 0 0 1 24.296683 20.084327v51.362816a13.358521 13.358521 0 0 0 3.878781 9.471983 13.226643 13.226643 0 0 0 9.425437 3.917568l114.400762-0.170666h13.110279a13.358521 13.358521 0 0 0 9.495255-3.917569 13.451612 13.451612 0 0 0 3.878781-9.495255l0.054303-29.168432a13.226643 13.226643 0 0 0-0.387878-3.157327l-0.558544-2.327269a88.436203 88.436203 0 0 0-6.826655-27.570374c-25.553408-59.453953-131.288973-104.292659-245.914704-104.292659h-0.589575a511.355191 511.355191 0 0 0-134.903997 18.967238c-45.102463 12.776704-80.802762 30.681156-100.514726 50.424151a114.990336 114.990336 0 0 0-24.901773 35.645996 86.357176 86.357176 0 0 0-7.57138 34.505634l-0.054303 27.787586z" fill="#FC4A46" p-id="2780"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
<svg t="1689750291003" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2521" width="200" height="200"><path d="M837.603556 277.134222a2.275556 2.275556 0 0 0-0.056889-0.483555c-0.199111-0.654222 14.848 44.856889-0.597334-1.934223a88.462222 88.462222 0 0 0-0.995555-2.929777c-14.961778-42.496-39.224889-58.965333-57.002667-62.321778-22.840889-4.352-47.587556 5.774222-47.587555 35.498667 0 4.437333 0.853333 9.272889 2.417777 14.250666l-0.142222-0.028444c0.085333 0.256 0.227556 0.512 0.369778 0.768 1.621333 5.063111 3.953778 10.268444 6.769778 15.445333 13.767111 34.019556 21.333333 71.224889 21.333333 110.165333 0 76.117333-28.956444 145.521778-76.458667 197.802667 102.030222 60.273778 177.208889 184.149333 203.064889 303.672889H981.333333c-21.162667-119.978667-82.915556-248.632889-170.524444-325.632a385.194667 385.194667 0 0 0 42.154667-175.843556 386.275556 386.275556 0 0 0-15.36-108.430222zM429.056 637.013333a250.026667 250.026667 0 1 0-0.028444-500.081777 250.026667 250.026667 0 0 0 0.028444 500.081777z m158.151111-253.354666a161.479111 161.479111 0 1 1-322.958222 0 161.479111 161.479111 0 0 1 322.958222 0z m182.784 503.381333c-30.663111-180.878222-180.707556-340.935111-352.483555-340.935111-191.744 0-343.950222 160.056889-374.840889 340.935111h91.306666c29.354667-130.304 141.624889-250.026667 283.534223-250.026667 122.737778 0 232.248889 119.694222 261.205333 250.026667h91.278222z" p-id="2522"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg t="1691555195526" class="icon" viewBox="0 0 1152 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2625" width="200" height="200"><path d="M1091.242667 116.032c31.722667 10.005333 54.592 39.253333 54.741333 73.536v630.250667c0 28.928-16.618667 55.424-43.029333 68.672L714.368 1006.72c-4.309333 0.704-8.597333 1.28-13.162667 1.28-35.029333 0-63.488-27.733333-63.488-61.994667V61.845333C637.717333 27.733333 666.176 0 701.205333 0c4.416 0 8.832 0.405333 13.162667 1.258667L1093.504 116.693333c-0.554667-0.128-1.429333-0.426667-2.261333-0.682666z m-40.618667 676.757333V215.04L733.077333 132.586667v742.656l317.546667-82.453334zM450.944 0c35.2 0 63.637333 27.733333 63.338667 61.845333v884.16c0 34.282667-28.437333 61.994667-63.466667 61.994667-4.48 0-8.917333-0.426667-13.312-1.258667L49.194667 888.490667C23.594667 875.52 6.186667 849.621333 6.186667 819.690667V189.717333c0-35.52 24.746667-65.493333 58.346666-74.517333-0.277333 0.128-0.725333 0.128-1.28 0.256L437.781333 1.28c4.266667-0.704 8.576-1.258667 13.141334-1.258667z" fill="" p-id="2626"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg t="1691553973993" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2471" width="200" height="200"><path d="M896 467.2l-362.666667-341.333333c-2.133333-2.133333-6.4-4.266667-8.533333-6.4-4.266667-2.133333-6.4-2.133333-10.666667-2.133334s-8.533333 0-10.666666 2.133334c-4.266667 2.133333-6.4 4.266667-8.533334 6.4l-362.666666 341.333333c-12.8 12.8-12.8 32-2.133334 44.8 12.8 12.8 32 12.8 44.8 2.133333l309.333334-290.133333V874.666667c0 17.066667 14.933333 32 32 32s32-14.933333 32-32V224L853.333333 514.133333c6.4 6.4 14.933333 8.533333 21.333334 8.533334 8.533333 0 17.066667-4.266667 23.466666-10.666667 12.8-12.8 10.666667-32-2.133333-44.8z" fill="" p-id="2472"></path></svg>

After

Width:  |  Height:  |  Size: 725 B

View File

@@ -0,0 +1 @@
<svg t="1690124637853" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1266" width="200" height="200"><path d="M512 512m-511.982387 0a511.982387 511.982387 0 1 0 1023.964774 0 511.982387 511.982387 0 1 0-1023.964774 0Z" fill="#F9C228" p-id="1267"></path><path d="M917.619539 199.639491C761.536154-3.082215 483.485105-56.554248 265.951152 62.930031a531.620502 531.620502 0 0 0-166.598142 387.161472c0 294.430822 238.686756 533.099966 533.099966 533.099966a535.424837 535.424837 0 0 0 101.219951-9.598899 514.289646 514.289646 0 0 0 90.705195-55.973031C1048.393533 745.138768 1090.223598 423.672515 917.619539 199.639491z" fill="#FCDC22" p-id="1268"></path><path d="M972.042656 550.272308c-111.329618 114.904988-252.600757 198.670795-415.482628 260.033299 0 0 55.867355 156.664603 207.829377 101.53698 250.628139-90.846096 207.653251-361.570279 207.653251-361.570279z" fill="#FC9B88" p-id="1269"></path><path d="M522.690884 570.08655a80.119986 64.145304 90 1 0 128.290609 0 80.119986 64.145304 90 1 0-128.290609 0Z" fill="#282828" p-id="1270"></path><path d="M860.519298 429.449467a80.119986 54.881046 90 1 0 109.762092 0 80.119986 54.881046 90 1 0-109.762092 0Z" fill="#282828" p-id="1271"></path><path d="M953.813553 724.584795a261.319023 261.319023 0 0 0-116.014585-59.988717C754.121225 723.616099 659.699759 771.434469 556.560028 810.305607c0 0 55.867355 156.664603 207.829377 101.53698 110.959752-40.209701 164.361335-115.64472 189.424148-187.257792z" fill="#EA0F1A" p-id="1272"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,19 @@
<script lang="ts" setup>
defineOptions({
name: 'BackPreviousLevel',
});
const emits = defineEmits(['back']);
</script>
<template>
<div class="flex items-center">
<button
class="btn-circle btn border-0 bg-transparent"
@click="emits('back')"
>
<svg-icon name="back" class="h-6 w-6" />
</button>
<slot></slot>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script lang="ts" setup>
import { PropType } from 'vue';
defineOptions({
name: 'BackTitle',
});
const props = defineProps({
svgName: {
type: String as PropType<string>,
required: true,
},
title: {
type: String as PropType<string>,
default: '',
},
});
</script>
<template>
<div class="flex items-center">
<!-- <svg-icon :name="props.svgName" class="mr-1 h-8 w-8"></svg-icon> -->
<div v-if="props.title">{{ props.title }}</div>
<slot v-else></slot>
</div>
</template>

View File

@@ -0,0 +1,2 @@
export { default as BackPreviousLevel } from './back-previous-level.vue';
export { default as BackTitle } from './back-title.vue';

View File

@@ -0,0 +1,34 @@
<script lang="ts" setup>
import { useVModel } from '@vueuse/core';
import { PropType } from 'vue';
defineOptions({
name: 'DropDown',
});
const props = defineProps({
visible: {
type: Boolean as PropType<boolean>,
default: false,
},
});
const emits = defineEmits(['update:visible']);
const visibleVm = useVModel(props, 'visible', emits);
</script>
<template>
<div class="dropdown-top dropdown-end dropdown">
<label tabindex="0">
<slot></slot>
</label>
<div
v-if="visibleVm"
tabindex="0"
class="dropdown-content menu rounded-box z-[1] bg-base-100 shadow"
>
<slot name="content"></slot>
</div>
</div>
</template>

View File

@@ -1,3 +1,5 @@
export { default as SvgIcon } from './svg-icon.vue';
export { default as NavIcons } from './nav-icons.vue';
export { default as Modal } from './modal.vue';
export { default as Dropdown } from './dropdown.vue';
export { default as ToastBox } from './toast.vue';

View File

@@ -1,11 +1,15 @@
<script lang="ts" setup>
import { useSwitchTheme, ThemeEnum } from '@/hooks';
import { computed } from 'vue';
import { useSocketCount } from '@/hooks';
defineOptions({
name: 'NavIcons',
});
// 获取 当前在线人数
const { data: curCount } = useSocketCount();
const { themeInfo, setTheme, isDark } = useSwitchTheme();
const themeIcon = computed(() =>
@@ -18,9 +22,16 @@ const switchTheme = () =>
<template>
<div>
<div class="flex items-center text-sm">
<svg-icon name="count" class="mr-2 h-3 w-3" />
在线人数
<div class="font-semibold">
{{ curCount }}
</div>
</div>
<a
href="https://github.com/tl-open-source/tl-rtc-file"
class="cursor-pointer"
class="ml-8 cursor-pointer"
>
<svg-icon
name="github"

View File

@@ -0,0 +1,73 @@
<script lang="ts" setup>
import { useVModel } from '@vueuse/core';
defineOptions({
name: 'ToastBox',
});
type ToastTypes = 'info' | 'success' | 'warning' | 'error';
interface ToastProps {
msg?: string;
type?: ToastTypes;
closeable?: boolean;
visible: boolean;
horizontal?: 'left' | 'end' | 'center';
vertical?: 'top' | 'middle' | 'bottom';
}
const props = withDefaults(defineProps<ToastProps>(), {
msg: '',
type: 'info',
closeable: false,
visible: false,
horizontal: 'left',
vertical: 'top',
});
const emits = defineEmits(['update:visible']);
const toastType = {
info: 'alert-info',
success: 'alert-success',
warning: 'alert-warning',
error: 'alert-error',
};
const horizontalType = {
left: 'toast-start',
end: 'toast-end',
center: 'toast-center',
};
const verticalType = {
top: 'toast-top',
middle: 'toast-middle',
bottom: 'toast-bottom',
};
const visiblevm = useVModel(props, 'visible', emits);
setTimeout(() => {
visiblevm.value = false;
}, 1500);
</script>
<template>
<div
v-if="visiblevm"
class="toast"
:class="[horizontalType[props.horizontal], verticalType[props.vertical]]"
>
<div class="alert relative" :class="toastType[props.type]">
<button
v-if="props.closeable"
class="btn-ghost btn-xs btn-circle btn absolute right-1 top-1"
@click="visiblevm = false"
>
x
</button>
<span>{{ props.msg }}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
import ChatMessage from './chat-message.vue';
defineOptions({
name: 'ChatContent',
});
const props = defineProps<{
msgList: {
time: string;
username: string;
message: string;
reverse: boolean;
}[];
}>();
</script>
<template>
<div class="flex flex-col justify-end overflow-y-auto overflow-x-hidden">
<ChatMessage
v-for="(item, index) in props.msgList"
:key="index"
class="mb-4"
:time="item.time"
:username="item.username"
:message="item.message"
:reverse="item.reverse"
:background="item.reverse ? 'bg-success' : ''"
/>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useVModel } from '@vueuse/core';
defineOptions({
name: 'ChatInput',
});
const props = defineProps({
msg: {
type: String,
default: '',
},
});
const emits = defineEmits(['sendMsg', 'update:msg']);
const msg = useVModel(props, 'msg', emits);
const canSend = computed(() => msg.value !== '');
const handleSend = () => {
if (canSend.value) {
emits('sendMsg', msg.value);
msg.value = '';
}
};
const keyDownEvent = (e: any) => {
if (e.ctrlKey && e.keyCode === 13) {
handleSend();
}
};
</script>
<template>
<div class="flex flex-col rounded-lg border border-info">
<textarea
v-model="msg"
class="textarea flex-1 resize-none border-b-0 focus:outline-none"
placeholder="请输入聊天消息 使用 Ctrl + Enter 发送"
@keydown="keyDownEvent"
></textarea>
<div class="mb-2 flex justify-end pr-4">
<button
class="btn-info btn px-3"
:class="{ 'btn-disabled': !canSend }"
@click="handleSend"
>
发送
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
import UserCard from './user-card.vue';
import { UserCardProps } from './props';
type ChatMessageProps = Partial<
UserCardProps & {
message: string;
background: string;
}
>;
defineOptions({
name: 'ChatMessage',
});
const props = withDefaults(defineProps<ChatMessageProps>(), {
iconname: 'user-smail',
iconcolor: 'currentcolor',
message: '',
background: '',
reverse: false,
});
</script>
<template>
<UserCard v-bind="props">
<div
class="flex flex-wrap"
:class="[reverse ? 'justify-end' : 'justify-start']"
>
<div
class="max-w-[260px] whitespace-pre-wrap break-words rounded-lg px-4 py-2 leading-6 text-black lg:max-w-[500px]"
:class="[background ? background : 'bg-slate-200']"
>
{{ props.message }}
</div>
</div>
</UserCard>
</template>

View File

@@ -0,0 +1,86 @@
<script lang="ts" setup>
import { Member, useDragChangeSize } from '@/hooks';
import UserCard from './user-card.vue';
import { computed, ref } from 'vue';
import { PropType } from 'vue';
defineOptions({
name: 'ChatRoomUsers',
});
const props = defineProps({
members: {
type: Array as PropType<Partial<Member>[]>,
default: () => [],
},
roomOwner: {
type: Object as PropType<Partial<Member>>,
default: () => ({}),
},
self: {
type: Object as PropType<Partial<Member>>,
default: () => ({}),
},
width: {
type: String as PropType<string>,
default: '220px',
},
});
const memberwithoutOwner = computed(() =>
props.members.filter((item) => !item.owner)
);
const chatRoomUserRef = ref<any>();
const { style, canDragged } = useDragChangeSize(chatRoomUserRef, {
initialSize: {
width: props.width,
},
position: 'left',
persistentSize: {
height: '100%',
},
});
const reSizeClass = computed(() => {
if (canDragged.value.draggable) {
if (canDragged.value.position === 'left') {
return ['cursor-w-resize'];
}
}
return [];
});
</script>
<template>
<div
ref="chatRoomUserRef"
:class="[...reSizeClass]"
class="h-full overflow-auto px-2 py-2 text-sm"
:style="style"
>
<div class="mb-1 text-gray-400">房主</div>
<UserCard
class="mb-4"
iconcolor="#2F54EB"
:username="
roomOwner?.id === self?.id
? `${roomOwner?.nickName} (本人)`
: roomOwner?.nickName
"
/>
<div class="mb-1 text-gray-400">
房间成员 ({{ memberwithoutOwner.length }})
</div>
<UserCard
v-for="item in memberwithoutOwner"
:key="item.id"
:username="
item.id === self?.id ? `${item.nickName} (本人)` : item.nickName
"
iconname="user-smail"
/>
</div>
</template>

View File

@@ -0,0 +1,5 @@
export { default as ChatInput } from './chat-input.vue';
export { default as ChatMessage } from './chat-message.vue';
export { default as ChatRoomUser } from './chat-room-user.vue';
export { default as ChatContent } from './chat-content.vue';
export { default as UserCard } from './user-card.vue';

View File

@@ -0,0 +1,7 @@
export type UserCardProps = {
iconname?: string;
iconcolor?: string;
username?: string;
time?: string;
reverse?: boolean;
};

View File

@@ -0,0 +1,37 @@
<script lang="ts" setup>
import { UserCardProps } from './props';
defineOptions({
name: 'UserCard',
inheritAttrs: false,
});
withDefaults(defineProps<UserCardProps>(), {
iconname: 'member',
iconcolor: 'currentcolor',
username: '',
time: '',
reverse: false,
});
</script>
<template>
<div
class="user-card flex items-start"
:class="[reverse ? 'flex-row-reverse' : '']"
>
<button class="btn-square btn border-0 bg-transparent">
<svg-icon :name="iconname" class="h-8 w-8" :color="iconcolor" />
</button>
<div class="ml-1 flex flex-col text-sm">
<div class="mb-1" :class="[reverse ? 'text-right' : 'text-left']">
{{ username }}
<span v-if="time" class="ml-4 text-slate-700 dark:text-gray-300">{{
time
}}</span>
</div>
<slot></slot>
<!-- <div class="other-info"></div> -->
</div>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
import Popper from 'vue3-popper';
import EmojiPicker from 'vue3-emoji-picker';
import 'vue3-emoji-picker/css';
import { useTheme } from '@/hooks';
import { ref } from 'vue';
defineOptions({
name: 'EmojiPicker',
});
const emits = defineEmits(['emojiChange']);
const { isDark } = useTheme();
const visibleEmoji = ref(false);
const onSelectEmoji = (data: any) => {
emits('emojiChange', data);
};
</script>
<template>
<Popper
@open:popper="visibleEmoji = true"
@close:popper="visibleEmoji = false"
>
<slot></slot>
<template #content>
<EmojiPicker
v-if="visibleEmoji"
:native="true"
:theme="isDark ? 'dark' : 'light'"
@select="onSelectEmoji"
/>
</template>
</Popper>
</template>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
import { PropType } from 'vue';
defineOptions({
name: 'ErrorBoundary',
});
const props = defineProps({
tipsList: {
type: Array as PropType<string[]>,
default: () => [],
},
});
</script>
<template>
<div class="flex h-full w-full items-center justify-center">
<div v-if="props.tipsList.length">
<p class="mb-2 font-bold">无法显示也许可以帮到您</p>
<ul class="text-secondary">
<li v-for="(item, index) in props.tipsList" :key="index">
{{ index + 1 }}. {{ item }}
</li>
</ul>
</div>
<template v-else>
<slot></slot>
</template>
</div>
</template>

View File

@@ -2,7 +2,6 @@
import { useRoom } from '@/hooks';
import { useForm } from '../form-base';
import { computed } from 'vue';
import { watch } from 'vue';
defineOptions({
name: 'FormRoom',
@@ -16,13 +15,6 @@ const { validateRoomId } = useRoom(() => formData.value.roomId || '');
const roomIdValid = computed(() => !formErrors.value['roomId']);
watch(
() => roomIdValid.value,
(v) => {
console.log(v);
}
);
defineExpose({
onSubmit: handleSubmit,
resetForm: resetFields,

View File

@@ -1,8 +1,12 @@
import { SetupContext } from 'vue';
import { SvgIcon, NavIcons } from './base';
import { MenuSide } from './menu';
import { resetUrl } from '@/utils';
export const NavHeader = () => {
export const NavHeader = (
{ showSiderbar }: Partial<{ showSiderbar: boolean }>,
ctx: SetupContext
) => {
return (
<div class="drawer shadow-md dark:shadow-sm dark:shadow-neutral-600">
<input id="my-drawer-3" type="checkbox" class="drawer-toggle" />
@@ -10,22 +14,39 @@ export const NavHeader = () => {
{/* Navbar */}
<div class="navbar w-full">
<div class="flex-none lg:hidden">
<label for="my-drawer-3" class="btn-ghost btn-square btn">
<SvgIcon
name="nav-menu"
color="#A6ADBA"
class="inline-block h-6 w-6"
/>
</label>
{showSiderbar ? (
<label
for="my-drawer-3"
class="btn-ghost btn-square btn"
onClick={() => ctx.emit('toggle')}
>
<SvgIcon
name="nav-menu"
color="#A6ADBA"
class="inline-block h-6 w-6"
/>
</label>
) : undefined}
</div>
<div class="mx-2 flex-1 px-2">
<span class="cursor-pointer" onClick={resetUrl}>
Web-Rtc
</span>
</div>
<div class="mx-2 flex-1 px-2">Web-Rtc</div>
<NavIcons class="flex-none pr-3" />
</div>
</div>
<div class="drawer-side">
<label for="my-drawer-3" class="drawer-overlay"></label>
<MenuSide class="bg-base-200"></MenuSide>
</div>
{showSiderbar ? (
<div class="drawer-side z-50">
<label
for="my-drawer-3"
class="drawer-overlay"
onClick={() => ctx.emit('toggle')}
></label>
<MenuSide class="bg-base-200"></MenuSide>
</div>
) : undefined}
</div>
);
};
@@ -47,7 +68,7 @@ export const FullHeightFlexBox = (
};
return (
<div class={['flex', height[type], `flex-${dire}`]}>
<div class={['flex', height[type], `flex-${dire}`, 'min-h-0']}>
{ctx.slots.default?.()}
</div>
);

View File

@@ -0,0 +1,40 @@
<script lang="ts" setup generic="T">
defineOptions({
name: 'SelectList',
});
type SelectListPropsType<L> = {
list: L[];
uniKey?: string;
title?: string;
};
const emits = defineEmits(['selected']);
const props = withDefaults(defineProps<SelectListPropsType<T>>(), {
list: () => [] as T[],
uniKey: '',
title: '',
});
const handleSelected = (item: any) => {
emits('selected', item);
};
</script>
<template>
<div class="flex flex-col pb-1">
<div v-if="props.title" class="list-title select-none px-2 text-slate-500">
{{ props.title }}
</div>
<ul class="flex flex-col">
<li
v-for="(item, index) in props.list"
:key="props.uniKey ? (item as any)[props.uniKey] : index"
@click="handleSelected(item)"
>
<slot :item="item"></slot>
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<script lang="ts" setup>
import EmojiPicker from '@/components/emoji/index.vue';
defineOptions({
name: 'MenuActions',
});
type PropTypes = {
menuAction: {
name: string;
tip: string;
color?: string;
tipDir?: string;
btn?: boolean;
disabled?: boolean;
}[];
gap?: number;
};
const props = withDefaults(defineProps<PropTypes>(), {
menuAction: () => [] as PropTypes['menuAction'],
gap: 0,
});
const emits = defineEmits(['clickIcon']);
const handleClick = (name: string) => emits('clickIcon', name);
</script>
<template>
<div class="flex">
<div
v-for="(item, index) in props.menuAction"
:key="item.name"
:data-tip="item.tip"
class="tooltip"
:class="[item.tipDir ?? 'tooltip-bottom', props.gap ? 'first:ml-0' : '']"
:style="
props.gap && index === 0
? `margin-left: 0`
: props.gap
? `margin-left: ${props.gap}px`
: ''
"
>
<emoji-picker v-if="item.name === 'emoji'" v-bind="$attrs">
<svg-icon
:color="item.color"
:name="item.name"
class="h-6 w-6 cursor-pointer"
@click="handleClick(item.name)"
/>
</emoji-picker>
<button
v-else-if="item.btn"
class="btn-circle btn"
:disabled="item.disabled"
@click="handleClick(item.name)"
>
<svg-icon
:color="item.color"
:name="item.name"
class="h-6 w-6 cursor-pointer"
/>
</button>
<svg-icon
v-else
:color="item.color"
:name="item.name"
class="h-6 w-6 cursor-pointer"
@click="handleClick(item.name)"
/>
</div>
</div>
</template>

View File

@@ -20,6 +20,11 @@ const menuListData = ref([
icon: 'add-icon',
label: '聊天',
},
{
key: 'video',
icon: 'add-icon',
label: '视频聊天',
},
]);
const currentCreateRoomByKey = ref('');
@@ -56,7 +61,10 @@ const handleClose = () => {
class="flex items-center justify-between"
>
<div>{{ item.label }}</div>
<button class="btn-circle btn bg-transparent" @click="createRoom(item.key)">
<button
class="btn-circle btn border-0 bg-transparent"
@click="createRoom(item.key)"
>
<svg-icon :name="item.icon" class="h-5 w-5" />
</button>
</div>

View File

@@ -7,7 +7,7 @@ defineOptions({
</script>
<template>
<div class="menu h-full w-80 p-4">
<div class="menu h-full w-80 min-w-[320px] p-4">
<MenuList />
</div>
</template>

View File

@@ -0,0 +1,3 @@
export const enum ConfigEnum {
useRelay = 'tl-rtc-file-use-relay',
}

View File

@@ -0,0 +1,53 @@
export type MenuActionType = {
name: string;
tip: string;
color?: string;
tipDir?: string;
btn?: boolean;
disabled?: boolean;
};
export const ChatAction: MenuActionType[] = [
{ name: 'member', tip: '显示成员', color: undefined },
];
export const ChatInputAction: MenuActionType[] = [
{ name: 'emoji', tip: '表情' },
];
export const VideoMenuAction: MenuActionType[] = [
{
name: 'member',
tip: '显示成员',
color: undefined,
tipDir: 'tooltip-top',
btn: true,
},
];
export const VideoControlMenuAction: MenuActionType[] = [
{
name: 'camera',
tip: '开启/关闭摄像头',
color: '#707070',
tipDir: 'tooltip-top',
btn: true,
disabled: false,
},
// {
// name: 'mirror-image',
// tip: '开启镜像',
// color: '#707070',
// tipDir: 'tooltip-top',
// btn: true,
// disabled: false,
// },
{
name: 'hang-up',
tip: '结束通话',
color: undefined,
tipDir: 'tooltip-top',
btn: true,
disabled: false,
},
];

View File

@@ -0,0 +1,3 @@
export * from './config-enum';
export * from './constant';
export * from './socket-event-name';

View File

@@ -0,0 +1,15 @@
export const enum SocketEventName {
Count = 'count',
CreateAndJoin = 'createAndJoin',
RoomCreated = 'created',
RoomExit = 'exit',
RoomJoin = 'joined',
RoomOffer = 'offer',
RoomAnswer = 'answer',
RoomCandidate = 'candidate',
}
// chat 的事件名
export const enum ChatEventName {
ChatingRoom = 'chatingRoom',
}

View File

@@ -0,0 +1,15 @@
import { InjectionKey, Ref } from 'vue';
export type InitDataKeyType = Partial<{
langMode: string;
socket: any;
logo: string;
version: string;
options: RTCOfferOptions;
config: {
iceServers: RTCIceServer[];
};
}>;
export const InitDataKey: InjectionKey<Ref<InitDataKeyType>> =
Symbol('initDataKey');

2
client/packages/rtc-web/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare module 'socket.io-client';
declare module 'vue3-emoji-picker';

View File

@@ -1,2 +1,9 @@
export * from './useTheme';
export * from './useRoom';
export * from './useInitData';
export * from './socket-utils';
export * from './useRouterReactive';
export * from './useDragChangeSize';
export * from './useSwitchByUrl';
export * from './useErrorCaptured';
export * from './useUseragent';

View File

@@ -0,0 +1,2 @@
export * from './useSocket';
export * from './useSocketUtils';

View File

@@ -0,0 +1,21 @@
import { InitDataKey } from '@/context';
import { CommonFnType } from '@/types';
import { inject, watch } from 'vue';
export const useSocket = (fn?: CommonFnType) => {
const initData = inject(InitDataKey);
if (initData) {
watch(
() => initData.value.socket,
async (v) => {
if (v) {
await fn?.(v);
}
},
{
immediate: true,
}
);
}
};

View File

@@ -0,0 +1,18 @@
import { SocketEventName } from '@/config';
import { useSocket } from './useSocket';
import { ref } from 'vue';
export const useSocketCount = () => {
const count = ref(0);
const handleSocketCount = (socket: any) => {
socket.on(SocketEventName.Count, (data: any) => {
count.value = data.mc;
});
};
useSocket(handleSocketCount);
return {
data: count,
};
};

View File

@@ -0,0 +1,128 @@
/**
* @description 拖拽元素改变大小
* 对于一些自由拖拽的元素会有一些问题,比如拉左边进行宽度增加,会造成 width 往右边增加;这里暂时未处理,按理说要同时改变 left
*/
import { isItBetween } from '@/utils';
import {
MaybeElementRef,
useMouseInElement,
useMousePressed,
} from '@vueuse/core';
import { computed, ref, watch } from 'vue';
type ResizeOption = {
initialSize?: {
width?: string;
height?: string;
};
position?: 'left' | 'right' | 'top' | 'bottom';
persistentSize?: {
width?: string;
height?: string;
}; // 用来控制在拖拽时不会变的数据
};
export const useDragChangeSize = (
target: MaybeElementRef,
options?: ResizeOption
) => {
const offset = 5;
const { elementX, elementY, elementWidth, elementHeight } =
useMouseInElement(target);
const style = computed(() => {
return Object.assign(
{},
{
width: `${
elementWidth.value
? elementWidth.value + 'px'
: options?.initialSize?.width ?? '100%'
}`,
height: `${
elementHeight.value
? elementHeight.value + 'px'
: options?.initialSize?.height ?? '100%'
}`,
},
options?.persistentSize || ({} as any)
);
});
const { pressed } = useMousePressed({ target, touch: false });
const draggablePos = computed(() => ({
left: {
x: [-offset, offset],
y: [0, elementHeight.value],
},
top: {
x: [0, elementWidth.value],
y: [-offset, offset],
},
}));
const judgeEnterPos = () => {
const findV = Object.keys(draggablePos.value).find((key) => {
const posKey = key as keyof typeof draggablePos.value;
return (
isItBetween(elementX.value, draggablePos.value[posKey].x) &&
isItBetween(elementY.value, draggablePos.value[posKey].y)
);
});
const draggable = options?.position ? options.position === findV : !!findV;
return {
draggable,
position: findV || '',
};
};
// 判断点击时候是否在拖拽范围内
const draggableByClick = ref({
draggable: false,
position: '',
});
// 供外部用的判断 是否可以拖拽
const canDragged = computed(judgeEnterPos);
const doResize = ([posX, posY]: number[], type: string) => {
const movX = posX - offset;
const movY = posY - offset;
if (type === 'left') {
const differenceX = -movX;
elementWidth.value = elementWidth.value + differenceX;
}
if (type === 'top') {
const differenceY = -movY;
elementHeight.value = elementHeight.value + differenceY;
}
};
watch([elementX, elementY], (v) => {
if (pressed.value) {
if (draggableByClick.value.draggable) {
doResize(v, draggableByClick.value.position);
}
}
});
watch(pressed, (v) => {
if (v) {
draggableByClick.value = judgeEnterPos();
} else {
draggableByClick.value = {
draggable: false,
position: '',
};
}
});
return {
canDragged,
style,
};
};

View File

@@ -0,0 +1,12 @@
import { onErrorCaptured, ref } from 'vue';
// 捕获 组件error
export const useErrorCaptured = () => {
const errors = ref<string[]>([]);
onErrorCaptured((e) => {
errors.value.push(e.message);
});
return errors;
};

View File

@@ -0,0 +1,47 @@
import { ConfigEnum } from '@/config';
import { useFetch, useLocalStorage } from '@vueuse/core';
import { ref, shallowReactive } from 'vue';
import io from 'socket.io-client';
import { InitDataKeyType } from '@/context';
import { isDev } from '@/utils';
export const useFetchData = async () => {
const useTurn = useLocalStorage(ConfigEnum.useRelay, isDev() ? false : true);
const { data, error } = await useFetch(
() => `/api/comm/initData?turn=${useTurn.value}`
)
.get()
.json();
return { data, error };
};
export const useInitData = async () => {
const { data, error } = await useFetchData();
const initData = ref<InitDataKeyType>({
langMode: 'zh', // 默认中文
});
if (data.value) {
const { wsHost, logo, version, rtcConfig, options } = data.value;
initData.value = Object.assign({}, initData.value, {
socket: wsHost ? shallowReactive(io(wsHost)) : null,
logo,
version,
options: Object.keys(options).reduce(
(cur, next) => ({ ...cur, [next]: Boolean(options[next]) }),
{}
),
config: rtcConfig,
});
}
if (error.value) {
throw error.value;
}
return {
initData,
};
};

View File

@@ -1,6 +1,53 @@
import { CommonFnType } from '@/types';
import { resolveRef } from '@/utils/reactive';
import { MaybeRef, computed } from 'vue';
import {
MaybeRef,
computed,
inject,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
watch,
} from 'vue';
import { useSocket } from './socket-utils';
import { SocketEventName } from '@/config';
import { useRouteParamsReactive, useUserAgent } from '.';
import { InitDataKey } from '@/context';
import { genNickName, isDev, resetUrl } from '@/utils';
import { uniqBy } from 'lodash';
import { watchArray } from '@vueuse/core';
export type Member = {
id: string;
nickName: string;
langMode: string;
owner: boolean;
ua: string;
joinTime: string;
userAgent: string;
ip: string;
network: string;
room?: string;
roomInfo?: OwnerInfo;
};
export type OwnerInfo = {
socketId: string;
roomId: string;
recoderId: string;
owner: boolean;
};
export type ConnectOption = {
roomJoined?: (...args: any[]) => Promise<void>;
roomCreated?: (data: any) => Promise<void>;
onAddRtcPeer?: (id: string, pc: any) => Promise<void>;
onConnectComplete?: (...args: any[]) => void;
onBeforeCreateOffer?: (...args: any[]) => Promise<void>;
onBeforeCreateAnswer?: (...args: any[]) => Promise<void>;
onTrack?: (...args: any[]) => void;
};
export const useRoom = (value: MaybeRef<string> | CommonFnType) => {
const realValue = resolveRef(value);
@@ -17,3 +64,317 @@ export const useRoom = (value: MaybeRef<string> | CommonFnType) => {
isValid,
};
};
export const useCreateRoom = (
type: 'password' | 'video' = 'password',
validate = true
) => {
const initData = inject(InitDataKey);
const { getNetWorkState, isMobile } = useUserAgent();
const { roomId } = useRouteParamsReactive(['roomId']);
const { isValid } = useRoom(() => roomId.value || '');
const emitCreateRoom = (socket: any) => {
socket.emit(SocketEventName.CreateAndJoin, {
room: roomId.value,
type: type,
password: '',
nickName: genNickName(),
langMode: initData?.value.langMode,
ua: isMobile ? 'mobile' : 'pc',
network: getNetWorkState(),
});
};
const checkParams = () => {
console.log('check');
if (validate) {
if (!isValid.value) {
resetUrl();
} else {
useSocket(emitCreateRoom);
}
}
};
watch(
() => isValid.value,
() => {
checkParams();
}
);
onMounted(checkParams);
return {
roomId,
isValid,
};
};
export const useRoomConnect = (option: ConnectOption = {}) => {
const roomInfo = useGetRoomInfo();
const { members, selfInfo } = roomInfo;
const rtcConnects = new Map<string, RTCPeerConnection>();
const dataChanelMap = new Map<string, RTCDataChannel>();
const initData = inject(InitDataKey)!;
const socketRef = shallowRef();
const completed = ref(false);
watchArray(
() => members.value,
async (_, __, added, removed) => {
console.log('watch', added, removed);
// 处理 exit
if (removed.length) {
removed.forEach(async (peer) => {
if (peer.id) {
const rtcConnect = await getRtcConnect(peer.id);
rtcConnect.close();
rtcConnects.delete(peer.id);
}
});
}
}
);
const createRtcConnect = async (id: string) => {
const pc = new RTCPeerConnection(
isDev() ? undefined : initData.value.config
);
pc.onicecandidate = (event) => {
if (event.candidate != null) {
const message = {
from: selfInfo.value.socketId,
to: id,
room: selfInfo.value.roomId,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex,
sdp: event.candidate.candidate,
};
socketRef.value.emit('candidate', message);
}
};
const dataChanel = pc.createDataChannel('datachanel');
dataChanelMap.set(id, dataChanel);
dataChanel.onopen = () => {
// dataChanel.send('aaa');
};
pc.ondatachannel = (e) => {
const chanel = e.channel;
if (chanel.label === 'datachanel') {
chanel.onmessage = () => {
// console.log('接受', e.data);
};
}
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'connected') {
// console.log('完成');
completed.value = true;
}
};
pc.ontrack = (e) => {
option?.onTrack?.(e, id);
};
rtcConnects.set(id, pc);
await option.onAddRtcPeer?.(id, pc);
return pc;
};
const getRtcConnect = async (id: string) => {
return rtcConnects.get(id) || (await createRtcConnect(id));
};
const roomCreated = async (data: any) => {
await option?.roomCreated?.(data);
};
const roomJoined = async (data: any) => {
const peer = await getRtcConnect(data.id);
await option?.roomJoined?.(data.id, peer);
createOffer(peer, data);
};
/**
* @description 这里的 createOffer
*/
const createOffer = async (pc: RTCPeerConnection, peer: any) => {
await option?.onBeforeCreateOffer?.(peer.id, pc);
const offer = await pc.createOffer(initData.value.options);
await pc.setLocalDescription(offer);
console.log('create offer - send');
socketRef.value.emit(SocketEventName.RoomOffer, {
from: selfInfo.value.socketId,
to: peer.id,
room: selfInfo.value.roomId,
sdp: offer.sdp,
});
};
/**
* @description offer 监听事件
*/
const roomOffer = async (data: any) => {
const pc = await getRtcConnect(data.from);
await pc.setRemoteDescription(
new RTCSessionDescription({ type: 'offer', sdp: data.sdp })
);
await option?.onBeforeCreateAnswer?.(data.from, pc);
const answer = await pc.createAnswer(initData.value.options);
await pc.setLocalDescription(answer);
console.log('receive offer - send answer');
socketRef.value.emit(SocketEventName.RoomAnswer, {
from: selfInfo.value.socketId,
to: data.from,
room: selfInfo.value.roomId,
sdp: answer.sdp,
});
};
/**
* @description answer 监听事件
*/
const roomAnswer = async (data: any) => {
const pc = await getRtcConnect(data.from);
console.log('receive answer');
await pc.setRemoteDescription(
new RTCSessionDescription({ type: 'answer', sdp: data.sdp })
);
};
const roomCandidate = async (data: any) => {
const pc = await getRtcConnect(data.from);
const rtcIceCandidate = new RTCIceCandidate({
candidate: data.sdp,
sdpMid: data.sdpMid,
sdpMLineIndex: data.sdpMLineIndex,
});
await pc.addIceCandidate(rtcIceCandidate);
};
const handleRoomConnect = (socket: any) => {
socketRef.value = socket;
startConnect();
};
const startConnect = () => {
socketRef.value.on(SocketEventName.RoomCreated, roomCreated);
socketRef.value.on(SocketEventName.RoomJoin, roomJoined);
socketRef.value.on(SocketEventName.RoomOffer, roomOffer);
socketRef.value.on(SocketEventName.RoomAnswer, roomAnswer);
socketRef.value.on(SocketEventName.RoomCandidate, roomCandidate);
};
useSocket(handleRoomConnect);
return { ...roomInfo, rtcConnects, dataChanelMap, completed, startConnect };
};
// 获取房间的一些信息,例如 peer 等信息
export const useGetRoomInfo = () => {
const members = ref<Partial<Member>[]>([]);
const selfInfo = ref<OwnerInfo>({
socketId: '',
owner: false,
recoderId: '',
roomId: '',
});
const roomCreated = (data: any) => {
members.value = uniqBy(
[
...data.peers.map((peer: any) => ({
id: peer.id,
nickName: peer.nickName,
owner: peer.owner,
})),
{
id: data.id,
owner: data.owner,
nickName: data.nickName,
},
],
'id'
);
selfInfo.value = {
socketId: data.id,
owner: data.owner,
recoderId: data.recoderId,
roomId: data.room,
};
console.log('created', members, data);
};
const roomExit = async (data: any) => {
console.log('exit', data);
members.value = members.value.filter((item) => item.id !== data.from);
};
const roomJoin = async (data: any) => {
console.log('join', data);
members.value = uniqBy(
[
...members.value,
{
id: data.id,
nickName: data.nickName,
owner: data.owner,
},
],
'id'
);
};
const roomOwner = computed(
() => members.value.find((item) => item.owner) || undefined
);
const self = computed(() => {
const info =
members.value.find((item) => item.id === selfInfo.value.socketId) ||
undefined;
if (info) {
return Object.assign({}, { ...info, roomInfo: selfInfo.value });
}
return undefined;
});
const handleRoomInfo = async (socket: any) => {
socket.on(SocketEventName.RoomCreated, roomCreated);
socket.on(SocketEventName.RoomExit, roomExit);
socket.on(SocketEventName.RoomJoin, roomJoin);
onBeforeUnmount(() => {
console.log('执行');
socket.emit(SocketEventName.RoomExit, {
from: selfInfo.value.socketId,
room: selfInfo.value.roomId,
recoderId: selfInfo.value.recoderId,
});
socket.removeAllListeners();
});
};
useSocket(handleRoomInfo);
return {
roomOwner,
members,
self,
selfInfo,
};
};

View File

@@ -0,0 +1,57 @@
import { useRouteParams, useRouteQuery } from '@vueuse/router';
import { Ref, toRefs, watch } from 'vue';
import { useRoute } from 'vue-router';
export const useRouteParamsReactive = <T extends string>(keys: T[]) => {
const params = keys.reduce(
(cur, next) => ({ ...cur, [next]: useRouteParams(next) }),
{} as Record<T, Ref<string | string[] | null>>
);
const route = useRoute();
watch(
() => route.params,
(v) => {
Object.keys(v).forEach((key) => {
if (params[key as T]) {
params[key as T].value = v[key];
}
});
},
{
immediate: true,
}
);
return params;
};
export const useRouteQueryReactive = (
...args: Parameters<typeof useRouteQuery>
) => {
const { query } = toRefs(useRoute());
const [key, defaultValue, options] = args;
const valueRef = useRouteQuery(key, defaultValue, options);
watch(
() => query.value[key],
(v: any) => {
const nv = options?.transform?.(v);
if (options?.transform === Number) {
if (!isNaN(nv as number)) {
valueRef.value = nv;
}
} else {
valueRef.value = nv;
}
},
{
immediate: true,
deep: true,
}
);
return valueRef;
};

View File

@@ -0,0 +1,55 @@
import { useWindowSize } from '@vueuse/core';
import { Ref, computed, watch } from 'vue';
import { useRouteQueryReactive } from './useRouterReactive';
export const useSwitchMember = () => {
const { width } = useWindowSize();
const showMembers = useRouteQueryReactive('showMembers', '0', {
transform: Number,
});
const switchMember = () => {
showMembers.value = showMembers.value === 0 ? 1 : 0;
};
const open = computed(() => showMembers.value === 1);
// 是否是宽屏, max-width = 1024
const isLgScreen = computed(() => width.value <= 1024);
watch(
() => isLgScreen.value,
(w) => {
showMembers.value = w ? 0 : 1;
},
{
immediate: true,
}
);
return {
open,
switchMember,
isLgScreen,
};
};
export const useSwitchSiderbar = () => {
const showSiderbar = useRouteQueryReactive('showSiderbar', '1', {
transform: Number,
}) as Ref<number>;
const switchSiderbar = () => {
showSiderbar.value = showSiderbar.value === 0 ? 1 : 0;
};
const open = computed(() => {
return showSiderbar.value === 1;
});
return {
switchSiderbar,
open,
showSiderbar,
};
};

View File

@@ -0,0 +1,40 @@
/* eslint-disable indent */
export const useUserAgent = () => {
const uerAgent = window.navigator.userAgent;
const isMobile = /Mobi|Android|iPhone/i.test(uerAgent);
const getNetWorkState = () => {
let networkStr = uerAgent.match(/NetType\/\w+/)
? uerAgent.match(/NetType\/\w+/)?.[0]
: 'NetType/other';
networkStr = networkStr?.toLowerCase().replace('nettype/', '');
if (
networkStr &&
!['wifi', '5g', '3g', '4g', '2g', '3gnet', 'slow-2g'].includes(networkStr)
) {
if ((navigator as any).connection) {
networkStr = (navigator as any).connection.effectiveType;
}
}
switch (networkStr) {
case 'wifi':
return 'wifi';
case '5g':
return '5g';
case '4g':
return '4g';
case '3g' || '3gnet':
return '3g';
case '2g' || 'slow-2g':
return '2g';
default:
return 'unknow';
}
};
return {
isMobile,
getNetWorkState,
};
};

View File

@@ -0,0 +1,15 @@
-----BEGIN CERTIFICATE-----
MIICVTCCAb4CCQCCDZ6FebPIqjANBgkqhkiG9w0BAQsFADBvMQswCQYDVQQGEwJV
UzENMAsGA1UECAwETWFyczETMBEGA1UEBwwKaVRyYW5zd2FycDETMBEGA1UECgwK
aVRyYW5zd2FycDETMBEGA1UECwwKaVRyYW5zd2FycDESMBAGA1UEAwwJMTI3LjAu
MC4xMB4XDTE4MDcxMDAzMTYzN1oXDTI4MDcwNzAzMTYzN1owbzELMAkGA1UEBhMC
VVMxDTALBgNVBAgMBE1hcnMxEzARBgNVBAcMCmlUcmFuc3dhcnAxEzARBgNVBAoM
CmlUcmFuc3dhcnAxEzARBgNVBAsMCmlUcmFuc3dhcnAxEjAQBgNVBAMMCTEyNy4w
LjAuMTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA1I6AQ6eNez85kcKjwy3g
/vcnXtw+EbP4Ab37fLhIIWG+XzmEBAqnCYjM3nmlDIGEfNylGReo9mD2OHg46a1D
wjd3pxTMit41pCTCiu8S9A2UJfbhSzQrfs+IZcNye4KR9/FzNEW6KoKQ0uc6X33E
0xe41hbRMQoKB3WmxvyN8PcCAwEAATANBgkqhkiG9w0BAQsFAAOBgQAd7GNDtKWA
0OpSCzMu0pbmss9Erh4/RC8D+wK4+TXgPDKyZ6hX4FYPvk+ryMvwxJf0o4jjx5cx
Yew7UjaKHlGXq+CNVRFYlltsbvO3oQTNkajuyYGWzMSuNxNsT3apOxH7SIu3qao8
COSwj5FxZ2JU7O+SBVFZoJrFXEa+KJMQzQ==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,15 @@
-----BEGIN RSA PRIVATE KEY-----
MIICXAIBAAKBgQDUjoBDp417PzmRwqPDLeD+9yde3D4Rs/gBvft8uEghYb5fOYQE
CqcJiMzeeaUMgYR83KUZF6j2YPY4eDjprUPCN3enFMyK3jWkJMKK7xL0DZQl9uFL
NCt+z4hlw3J7gpH38XM0RboqgpDS5zpffcTTF7jWFtExCgoHdabG/I3w9wIDAQAB
AoGAWsy1BjGhQrDzisy24D3NC53Q97jl2vIiU7wwnkqqpXf3tv3+4ysZx/zkZ3VX
iEwbqKso69srlnQ9OkpBJbGaa6lZe+z7BGzv2eJr+hKjjVjR122eDjAtXw+Tmt6c
iBUG9+ITC1GdhXLEgTXtYuPq8hbDhoAVI007E+5JuQoO8kECQQD3aR6zGfR7EOmn
byGkNMMPY/FoH894BpB4l7gIlNXH3pxBqukrEwnmVXSfak2PAkeLFO+bgCc6baIZ
+R4iOLn/AkEA2++dIndbY7nmGs7Q//sM7MkFMiFQ4h1nN38V9AEHaUonxlwo+Nks
PTAaVd78YIh6yBlfexm0Fxi1mEVQApqZCQJAFUPqyJglhGJqwuJxcMy8K1l6yWla
isV9q2/W+J3aViiTI63OBs7HHg4gTQd1DSK0BYdSJPp55LLBqRvZdDWN/wJBAMQa
M46exAL4p55xl9MWwyCB4LshD6B9vSGzlBx7qmMMNsjcNcAkzBhGwsScTYW5S1kN
nqABfB037/s0mjGoLRkCQF18j4MovyFaj8VAqY8YUmf86Ez9JmL9kHNA8yEoSjuI
xsRa6y5Nza5y83Mojt4W+PfS386riJ7txqrPdezyag4=
-----END RSA PRIVATE KEY-----

View File

@@ -1,20 +1,33 @@
<script lang="ts" setup>
import { MenuSide } from '@/components/menu';
import { NavHeader, FullHeightFlexBox } from '@/components/lib';
import { useInitData, useSwitchSiderbar } from '@/hooks';
import { provide } from 'vue';
import { InitDataKey } from '@/context';
defineOptions({
name: 'LayoutIndex',
});
const { showSiderbar, open } = useSwitchSiderbar();
const { initData } = await useInitData();
provide(InitDataKey, initData);
const handleToggleSide = () => {
showSiderbar.value = 1;
};
</script>
<template>
<FullHeightFlexBox dire="col">
<NavHeader />
<NavHeader :showSiderbar="open" @toggle="handleToggleSide" />
<FullHeightFlexBox type="full">
<MenuSide
v-if="open"
class="hidden dark:shadow-sm dark:shadow-neutral-600 lg:block lg:shadow-md"
/>
<RouterView />
<RouterView class="flex-1" />
</FullHeightFlexBox>
</FullHeightFlexBox>
</template>

View File

@@ -8,7 +8,16 @@ import SvgIcon from '@/components/base/svg-icon.vue';
import App from './App.vue';
import VConsole from 'vconsole';
import { useUserAgent } from '@/hooks';
import { isDev } from '@/utils';
const { isMobile } = useUserAgent();
function setup() {
if (isMobile && isDev()) {
new VConsole();
}
return createApp(App).use(router).component('svg-icon', SvgIcon);
}

View File

@@ -1,6 +1,10 @@
export const routes = [
{ path: '/', component: () => import('@/views/welcome.vue') },
{ path: '/chat/:roomId', component: () => import('@/views/chat/chat.vue') },
{
path: '/video/:roomId',
component: () => import('@/views/video/video.vue'),
},
// 将匹配所有内容并将其放在 `$route.params.pathMatch` 下
{
path: '/:pathMatch(.*)*',

View File

@@ -10,3 +10,87 @@ export function withBtnClickEvent(fn: CommonFnType) {
export function preventDefault(e: Event) {
e.preventDefault();
}
export function safenExecuteConditioFn(condition: boolean, fn: CommonFnType) {
if (condition) {
fn();
}
}
// 将 socket on 转换为 promisify
export function transformSocketListenEvent(socket: any, ev: string) {
return new Promise<any[]>((resolve) => {
const cb = (...args: any[]) => {
resolve(args);
};
socket.on(ev, cb);
});
}
// 转义字符串
export function escapeStr(str: string) {
const entityMap: any = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;',
};
const encodedMap: any = {
'%': '%25',
'!': '%21',
"'": '%27',
'(': '%28',
')': '%29',
'*': '%2A',
'-': '%2D',
'.': '%2E',
_: '%5F',
'~': '%7E',
};
return String(str).replace(/[&<>"'`=/%!'()*\-._~]/g, function (s) {
return entityMap[s] || encodedMap[s] || '';
});
}
export function unescapeStr(str: string) {
const entityMap: any = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&#x2F;': '/',
'&#x60;': '`',
'&#x3D;': '=',
};
const encodedMap: any = {
'%25': '%',
'%21': '!',
'%27': "'",
'%28': '(',
'%29': ')',
'%2A': '*',
'%2D': '-',
'%2E': '.',
'%5F': '_',
'%7E': '~',
};
return String(str).replace(
/&(amp|lt|gt|quot|#39|#x2F|#x60|#x3D);|%(25|21|27|28|29|2A|2D|2E|5F|7E)/g,
function (s) {
return entityMap[s] || encodedMap[s] || '';
}
);
}
export const isItBetween = (num: number, arr: number[]) => {
return num >= arr[0] && num <= arr[1];
};
export const resetUrl = () => window.location.replace('/');

View File

@@ -0,0 +1,7 @@
export const isProd = () => {
return import.meta.env.PROD;
};
export const isDev = () => {
return import.meta.env.DEV;
};

View File

@@ -1 +1,4 @@
export * from './common';
export * from './user';
export * from './reactive';
export * from './env';

View File

@@ -0,0 +1,503 @@
export const nameDataBase = () => {
const adjectives = [
'幽默的',
'搞笑的',
'疯狂的',
'奇怪的',
'古怪的',
'无聊的',
'神秘的',
'魔幻的',
'风趣的',
'调皮的',
'聪明的',
'美丽的',
'可爱的',
'迷人的',
'酷的',
'萌萌的',
'潇洒的',
'霸气的',
'猛烈的',
'光芒的',
'伶俐的',
'俏皮的',
'小巧的',
'细腻的',
'娇嫩的',
'柔软的',
'亲切的',
'朴实的',
'拘谨的',
'高傲的',
'自恋的',
'浪漫的',
'单纯的',
'深情的',
'执着的',
'冷酷的',
'刁蛮的',
'天真的',
'多情的',
'成熟的',
'忧郁的',
'神经质的',
'孤独的',
'怀旧的',
'清新的',
'淡雅的',
'冷艳的',
'高冷的',
'玩世不恭的',
'逆天的',
'暴躁的',
'暴力的',
'妩媚的',
'狡猾的',
'自信的',
'自卑的',
'悲观的',
'乐观的',
'勇敢的',
'胆小的',
'快乐的',
'痛苦的',
'善良的',
'邪恶的',
'深邃的',
'神圣的',
'丰满的',
'单薄的',
'肥胖的',
'瘦弱的',
'英俊的',
'丑陋的',
'芳香的',
'臭气熏天的',
'热情的',
'冷漠的',
'朝气蓬勃的',
'干净的',
'脏兮兮的',
'无忧无虑的',
'喜怒无常的',
'平凡的',
'非凡的',
'害羞的',
'热心的',
'机智的',
'敏捷的',
'迟钝的',
'聪慧的',
'无知的',
'真诚的',
'虚伪的',
'直率的',
'谨慎的',
'大胆的',
'谦虚的',
'傲慢的',
'严肃的',
'轻松的',
'紧张的',
'勤劳的',
'懒惰的',
'守时的',
'迟到的',
'坚强的',
'软弱的',
'聪慧的',
'愚笨的',
'机灵的',
'迟钝的',
'淘气的',
'乖巧的',
'活泼的',
'沉默的',
'健康的',
'不健康的',
'高大的',
'矮小的',
'长的',
'短的',
'胖的',
'瘦的',
'美满的',
'不幸的',
'富有的',
'贫穷的',
'快乐的',
'不开心的',
'甜美的',
'苦涩的',
'精明的',
'愚蠢的',
'聪明的',
'智商高的',
'心灵手巧的',
'笨手笨脚的',
'冷静的',
'冲动的',
'踏实的',
'轻浮的',
'温柔的',
'粗暴的',
'好学的',
'讨厌学习的',
'好吃的',
'不好吃的',
'耐心的',
'急躁的',
'友善的',
'冷漠的',
'豁达的',
'固执的',
'谨慎的',
'善良的',
'狠毒的',
'平和的',
'狂躁的',
'机会主义的',
'悲观的',
'乐观的',
'心胸开阔的',
'偏狭的',
'讲义气的',
'不守信用的',
'有魅力的',
'无趣的',
'有思想的',
'无聊的',
'谋略深的',
'目光短浅的',
'善解人意的',
'自私的',
'坦率的',
'虚伪的',
'好奇的',
'不解风情的',
'喜欢交友的',
'独来独往的',
'健谈的',
'静默的',
'喜欢思考的',
'机智幽默的',
'情感丰富的',
'心地善良的',
'充满自信的',
'天真烂漫的',
'追求完美的',
'充满活力的',
'喜欢冒险的',
'充满创造力的',
'沉着冷静的',
'目标明确的',
'性格温和的',
'乐于助人的',
'聪明伶俐的',
'重情重义的',
'思维敏捷的',
'慷慨大方的',
'婉约多姿的',
'时尚前卫的',
'豁达开朗的',
'气质高雅的',
'优雅大方的',
'沉静深沉的',
'坚韧不拔的',
'独立自主的',
'外向开朗的',
'内向沉默的',
'深情专注的',
'精力旺盛的',
'富于幽默的',
'心思细腻的',
'喜怒形于色的',
'忠心耿耿的',
'玩世不恭的',
'活力四射的',
'脚踏实地的',
'注重细节的',
'保守谨慎的',
'世故圆滑的',
'梦想家的',
'勇往直前的',
'干练果敢的',
'待人友善的',
'思想开放的',
'敢于挑战的',
'感性洒脱的',
'洒脱不羁的',
'自我牺牲的',
'处事果断的',
'好奇心强的',
'待人热情的',
'热情洋溢的',
'孤独悲伤的',
'浪漫多情的',
'爱笑的',
'不羁的',
'傻气的',
'不拘小节的',
'懒散的',
'无聊的',
'低调的',
'敏感的',
'冷酷的',
'专注的',
'不屑的',
'激情的',
'忠诚的',
'神秘的',
'高傲的',
'自由的',
'文艺的',
'时尚的',
'落落大方的',
'有才华的',
'有气质的',
'阳光的',
'风趣的',
'天真浪漫的',
'爽朗开朗的',
'内敛沉静的',
'刻苦努力的',
'性格迥异的',
'个性张扬的',
'脾气火爆的',
'傲娇的',
'爱撒娇的',
'心思缜密的',
'理智果断的',
'懒惰的',
'喜欢拖延的',
'有责任感的',
'追求自由的',
'感性的',
'理性的',
'缺乏安全感的',
'追求安全感的',
'情绪化的',
'乐观的',
'悲观的',
'现实主义的',
'理想主义的',
'平易近人的',
'目中无人的',
'重视亲情的',
'重视友情的',
'重视爱情的',
'有爱心的',
'有正义感的',
'有同情心的',
'有童心的',
'有自信的',
'胆小怕事的',
'爱唠叨的',
'话多的',
'话少的',
'勇于冒险的',
'爱挑战的',
'善于发现美的',
'自我意识强的',
'不喜欢被约束的',
'慢热型的',
'热情洋溢的',
'容易受伤的',
'重视感情的',
'善于沟通的',
'不善于表达的',
'有幽默感的',
'平易近人的',
'有亲和力的',
'脸皮厚的',
'喜欢交际的',
'宅男/宅女的',
'喜欢独处的',
'有自知之明的',
'喜欢音乐的',
'喜欢阅读的',
'喜欢旅行的',
'喜欢美食的',
'喜欢运动的',
'喜欢摄影的',
'喜欢收藏的',
'喜欢购物的',
'不喜欢出门的',
'喜欢挑剔的',
'喜欢自省的',
'不喜欢评价他人的',
'喜欢评价他人的',
'有品位的',
'不讲卫生的',
'讲究卫生的',
'喜欢干净的',
'喜欢乱的',
'喜欢组织的',
'喜欢随意的',
'喜欢收纳的',
];
const nouns = [
'狗子',
'猫咪',
'小鹿',
'小熊',
'小兔',
'小羊',
'小猪',
'小马',
'小狮子',
'小老虎',
'小猴子',
'小鱼儿',
'小乌龟',
'小鸟儿',
'小蚂蚁',
'小蜜蜂',
'小蝴蝶',
'小蜻蜓',
'小螃蟹',
'小章鱼',
'小海豚',
'小鲨鱼',
'小鲸鱼',
'小鳄鱼',
'小鸭子',
'小雪人',
'小皮球',
'小篮球',
'小足球',
'小排球',
'小棒球',
'小滑板',
'小冰棍',
'小雨伞',
'小手套',
'小电影',
'小蓝天',
'小公主',
'小王子',
'小玩具',
'小糖果',
'小巧克力',
'小冰淇淋',
'小蛋糕',
'小披萨',
'小汉堡',
'小炸鸡',
'小烤鸭',
'小鱼丸',
'小火锅',
'小串串',
'小煎饼',
'小油条',
'小葱油饼',
'小米粥',
'小酸奶',
'小豆腐',
'小饺子',
'小包子',
'小馄饨',
'小面条',
'小牛肉面',
'小糯米鸡',
'小蒸饺',
'小炒面',
'小蒸包',
'小烤肉',
'小烤串',
'小花生米',
'小太阳',
'小月亮',
'小星星',
'小彩虹',
'小风车',
'小气球',
'小钢琴',
'小吉他',
'小音响',
'小麦克风',
'小演员',
'小画家',
'小工程师',
'小医生',
'小警察',
'小消防员',
'小司机',
'小农民',
'小潜水员',
'小飞行员',
'小篮球',
'小游泳健将',
'小跑步冠军',
'小武术高手',
'小芭蕾舞者',
'小沙画家',
'小书法家',
'小拼图专家',
'小玩具收藏家',
'小电影制片人',
'小太空旅行家',
'超级英雄',
'无敌大魔王',
'终极霸主',
'至尊帝王',
'天降巨人',
'绝世奇才',
'神话之门',
'恐怖怪兽',
'魔法使者',
'神秘剑客',
'不朽传说',
'宇宙霸主',
'地狱火山',
'无尽黑暗',
'闪耀之星',
'璀璨之光',
'金色骑士',
'毁天灭地',
'战无不胜',
'碾压一切',
'绝世高手',
'超凡脱俗',
'万象之王',
'黑暗骑士',
'霸天战神',
'万众瞩目',
'震古烁今',
'纵横天下',
'永不磨灭',
'恒久不变',
'帝国之主',
'不屈不挠',
'狂暴之王',
'超越极限',
'魔力无边',
'星光闪耀',
'无尽追求',
'刀锋之舞',
'独步天下',
'吞噬万物',
'永恒之境',
'灭世战神',
'海量财富',
'神话传说',
'唯我独尊',
'万剑归宗',
'嗜血狂魔',
'深海之王',
'幻想之城',
'天命之子',
];
return { adjectives, nouns };
};
export function genNickName() {
const { adjectives, nouns } = nameDataBase();
const adjectiveIndex = Math.floor(Math.random() * adjectives.length);
const nounIndex = Math.floor(Math.random() * nouns.length);
const adjective = adjectives[adjectiveIndex];
const noun = nouns[nounIndex];
const randomNum = Math.floor(Math.random() * 1000);
return adjective + noun + randomNum;
}

View File

@@ -0,0 +1,119 @@
<script lang="ts" setup>
import { ChatContent, ChatRoomUser, ChatInput } from '@/components/chat-room';
import MenuAction from '@/components/menu-action.vue';
import { ChatInputAction } from '@/config';
import { useSwitchMember } from '@/hooks';
import { useGetRoomInfo } from '@/hooks/useRoom';
import { useChat } from './hooks/useChat';
import { escapeStr } from '@/utils';
import dayjs from 'dayjs';
import { computed, ref, nextTick } from 'vue';
import { useDragChangeSize } from '@/hooks';
defineOptions({
name: 'ChatRoomCom',
});
const { open } = useSwitchMember();
const { members, roomOwner, self } = useGetRoomInfo();
const { sendMessage, msgList } = useChat();
const inputMsg = ref('');
const chatMsgBoxRef = ref<any>(null);
const chatMsgContentRef = ref<Element | null>(null);
const { canDragged, style } = useDragChangeSize(chatMsgBoxRef, {
initialSize: { height: '260px' },
position: 'top',
persistentSize: {
width: '100%',
},
});
const reSizeClass = computed(() => {
if (canDragged.value.draggable) {
if (canDragged.value.position === 'top') {
return ['cursor-ns-resize'];
}
}
return [];
});
const handleSendmsg = (msg: string) => {
if (self.value) {
const { roomInfo, nickName = '' } = self.value;
sendMessage({
content: escapeStr(msg),
room: roomInfo?.roomId || '',
from: roomInfo?.socketId || '',
nickName,
recoderId: roomInfo?.recoderId,
time: dayjs().format('YYYY-MM-DD HH:mm:ss'),
});
nextTick(() => {
chatMsgContentRef?.value?.scroll({
top: chatMsgContentRef.value.scrollHeight,
left: 0,
behavior: 'smooth',
});
});
}
};
const msgContent = computed(() =>
msgList.value.map((item) => ({
message: item.content,
time: item.time,
username: item.nickName,
reverse: item.type === 'send',
}))
);
const handleEmojiChange = (data: any) => {
const unicode = parseInt(`0x${data.r}`, 16);
inputMsg.value += String.fromCodePoint(unicode);
};
</script>
<template>
<div class="flex h-full">
<div class="flex flex-1 flex-col">
<!-- 这里得多加一个 div 才能 scroll -->
<div
ref="chatMsgContentRef"
class="ces min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 py-4"
>
<ChatContent :msg-list="msgContent" />
</div>
<div
ref="chatMsgBoxRef"
:style="style"
:class="[...reSizeClass]"
class="flex h-[260px] max-h-[610px] flex-col border-t pb-1.5 dark:border-neutral-600"
>
<MenuAction
:menu-action="ChatInputAction"
class="pl-2 pt-2"
@emoji-change="handleEmojiChange"
/>
<ChatInput
v-model:msg="inputMsg"
class="mt-2 flex-1"
@send-msg="handleSendmsg"
/>
</div>
</div>
<ChatRoomUser
v-show="open"
class="hidden max-w-[400px] border-l dark:border-neutral-600 lg:block"
width="220px"
:members="members"
:room-owner="roomOwner"
:self="self"
/>
</div>
</template>

View File

@@ -1,27 +1,67 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoom } from '@/hooks';
import { ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useCreateRoom } from '@/hooks';
import { BackPreviousLevel, BackTitle } from '@/components/back';
import ChatRoomCom from './chat-room.vue';
import MenuAction from '@/components/menu-action.vue';
import { ChatAction } from '@/config';
import { useSwitchMember } from '@/hooks';
defineOptions({
name: 'ChatView',
});
const router = useRouter();
const { params } = useRoute();
const { isValid } = useRoom((params.roomId as string) || '');
const { roomId } = useCreateRoom();
const checkParams = () => {
console.log(isValid.value);
if (!isValid.value) router.replace('/');
const { switchMember, open, isLgScreen } = useSwitchMember();
const chatActionMenu = ref(ChatAction);
watch(
() => open.value,
(v) => {
const findv = chatActionMenu.value.find((item) => item.name === 'member');
if (findv) {
findv.color = v ? '#2F54EB' : undefined;
}
},
{ immediate: true }
);
const handleBackLevel = () => router.replace('/');
const handleClickIcon = (name: string) => {
if (name === 'member' && !isLgScreen.value) {
switchMember();
}
};
onMounted(checkParams);
</script>
<template>
<div>我是chat - {{ params.roomId }}</div>
<div class="flex w-full flex-col">
<BackPreviousLevel
class="py-2 shadow-md dark:shadow-sm dark:shadow-neutral-600"
@back="handleBackLevel"
>
<BackTitle svg-name="chat" class="flex-1 justify-between">
<div class="cursor-pointer">
{{ roomId }}
</div>
<MenuAction
:menu-action="chatActionMenu"
class="mr-6"
@click-icon="handleClickIcon"
/>
</BackTitle>
</BackPreviousLevel>
<div class="min-h-0 flex-1">
<ChatRoomCom />
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,57 @@
import { ChatEventName } from '@/config';
import { useSocket } from '@/hooks';
import { unescapeStr } from '@/utils';
import { ref, shallowRef } from 'vue';
export type SendMessage = {
content: string;
room: string;
from: string;
nickName: string;
recoderId: any;
time: string;
to?: string;
};
// export type MessageStatus = 'fail' | 'complete' | 'pending';
export type MessageType = 'send' | 'receive';
export type MessageInfo = {
content: string;
// status: MessageStatus;
type: MessageType;
} & SendMessage;
export const useChat = () => {
const socketRef = shallowRef();
const msgList = ref<MessageInfo[]>([]);
const handleChat = (socket: any) => {
socketRef.value = socket;
socket.on(ChatEventName.ChatingRoom, handleChatingRoom);
};
const handleChatingRoom = (data: SendMessage) => {
console.log('receive', data);
msgList.value.push({
...data,
content: unescapeStr(data.content),
type: 'receive',
});
};
const sendMessage = (data: SendMessage) => {
socketRef.value.emit(ChatEventName.ChatingRoom, data);
msgList.value.push({
...data,
content: unescapeStr(data.content),
type: 'send',
});
};
useSocket(handleChat);
return {
sendMessage,
msgList,
};
};

View File

@@ -0,0 +1,243 @@
import { useRoomConnect, useUserAgent } from '@/hooks';
import { useUserMedia, useDevicesList } from '@vueuse/core';
import { reject } from 'lodash';
import {
Ref,
computed,
onBeforeUnmount,
ref,
shallowRef,
watch,
watchEffect,
} from 'vue';
export type VideoShareOption = {
immeately?: boolean;
audio?: Ref<MediaDeviceInfo | undefined>;
speaker?: Ref<string>;
};
export const useMediaSetting = (
option: VideoShareOption = { immeately: true }
) => {
const currentCamera = ref<string>();
const currentAudioInput = ref<string>();
const currentAudioOutput = ref<string>();
const audioRef = ref(option.audio);
const speakerRef = ref(option.speaker);
const mediaLoaded = ref(false);
const { isMobile } = useUserAgent();
// 切换 麦克风
watch(
audioRef,
(value) => {
currentAudioInput.value = value?.deviceId;
},
{
immediate: true,
}
);
// 切换 扬声器
watch(
speakerRef,
(value) => {
currentAudioOutput.value = value;
},
{
immediate: true,
}
);
// 收集的错误信息
// const errorMsg = ref({
// audio: '',
// });
const {
videoInputs: cameras,
audioInputs,
audioOutputs,
} = useDevicesList({
requestPermissions: true,
onUpdated() {
// 初始化默认 摄像头、扬声器、麦克风
if (!cameras.value.find((i) => i.deviceId === currentCamera.value))
currentCamera.value = cameras.value[0]?.deviceId;
if (
!audioInputs.value.find((i) => i.deviceId === currentAudioInput.value)
)
currentAudioInput.value = audioInputs.value[0]?.deviceId;
if (
!audioOutputs.value.find((i) => i.deviceId === currentAudioOutput.value)
) {
currentAudioOutput.value = audioOutputs.value[0]?.deviceId;
}
},
});
const video = shallowRef<HTMLVideoElement>();
watch([currentAudioOutput, video], ([v1, v2]) => {
if (v1 && v2) {
setSinkId(v1);
}
});
const constraints = computed(() => {
const audioDevice = currentAudioInput.value
? { deviceId: currentAudioInput.value }
: false;
const videoDevice = currentCamera.value
? {
deviceId: currentCamera.value,
facingMode: 'user',
frameRate: {
ideal: 30,
max: 60,
},
width: { ideal: 1920 },
height: { ideal: 1280 },
}
: false;
return {
audio: audioDevice,
video: videoDevice,
};
});
const { stream, stop, restart, start, isSupported } = useUserMedia({
constraints,
});
const audioTracks = ref<MediaStreamTrack[]>([]);
const videoTracks = ref<MediaStreamTrack[]>([]);
const audioEnabled = ref(false);
const videoEnabled = ref(false);
// 切换 video、audio 渲染
const switchTrackEnable = (type: 'video' | 'audio', flag: boolean) => {
if (stream.value) {
if (type === 'audio') {
if (audioTracks.value?.length) {
audioEnabled.value = flag;
audioTracks.value.forEach((item) => (item.enabled = flag));
}
}
if (type === 'video') {
if (videoTracks.value?.length) {
videoEnabled.value = flag;
videoTracks.value.forEach((item) => {
item.enabled = flag;
});
}
}
}
};
const setSinkId = (sinkId: string) => {
if (!isMobile) {
(video.value! as any).setSinkId(sinkId);
}
};
// 先静音和关闭视频渲染
watch([stream, video], () => {
if (stream.value) {
if (video.value) {
video.value.srcObject = stream.value!;
if (stream.value) {
audioTracks.value = stream.value.getAudioTracks();
videoTracks.value = stream.value.getVideoTracks();
}
if (!videoEnabled.value) {
switchTrackEnable('video', false);
}
if (!audioEnabled.value) {
switchTrackEnable('audio', false);
}
mediaLoaded.value = true;
}
}
});
// 进入页面先连接
const startGetMedia = () => {
console.log(isSupported.value);
return new Promise<MediaStream | undefined>((resolve) => {
const watchEnableStop = watchEffect(async () => {
if (
currentAudioInput.value &&
currentCamera.value &&
currentAudioOutput.value
) {
const stream = await start();
watchEnableStop();
resolve(stream);
} else {
reject('请打开摄像头或者麦克风');
}
});
});
};
if (option.immeately) {
startGetMedia();
}
onBeforeUnmount(() => {
stop();
});
return {
startGetMedia,
stream,
video,
setSinkId,
switchTrackEnable,
restart,
stop,
audioEnabled,
videoEnabled,
mediaLoaded,
currentAudioInput,
currentAudioOutput,
};
};
export const useMediaConnect = (
stream: MediaStream,
connect: { onTrack?: (track: MediaStreamTrack, id: string) => void } = {}
) => {
useRoomConnect({
roomJoined: async (_, pc) => {
if (pc && stream) {
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
}
},
onBeforeCreateAnswer: async (_, pc) => {
if (pc && stream) {
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
}
},
onTrack(e: any, id: string) {
// console.log(e);
if (e.track.kind === 'video') {
connect.onTrack?.(e.streams[0], id);
}
},
});
};

View File

@@ -0,0 +1,158 @@
<script lang="ts" setup>
import { VideoControlMenuAction } from '@/config';
import { Modal } from '@/components/base';
import MenuAction from '@/components/menu-action.vue';
import { ref } from 'vue';
import { useDevicesList } from '@vueuse/core';
import SelectList from '@/components/list/select-list.vue';
import { Dropdown } from '@/components/base';
import { PropType } from 'vue';
import { computed } from 'vue';
import { resetUrl } from '@/utils';
import { nextTick } from 'vue';
defineOptions({
name: 'VideoControlMenu',
});
const props = defineProps({
activeControlMenu: {
type: Array as PropType<string[]>,
default: () => [],
},
menuDisabled: {
type: Boolean as PropType<boolean>,
default: false,
},
selectDevice: {
type: Object as PropType<Record<'audioInput' | 'audioOutput', string>>,
default: () => ({ audioInput: '', audioOutput: '' }),
},
});
const modalVisible = ref(false);
const emits = defineEmits(['controlMenuChange', 'selectDevice']);
const menuAction = computed(() => {
return VideoControlMenuAction.map((item) => ({
...item,
disabled: props.menuDisabled,
color: props.activeControlMenu.includes(item.name) ? '#2F54EB' : undefined,
}));
});
const { audioInputs, audioOutputs } = useDevicesList();
const audioDropDownVisible = ref(false);
const selectedAudioDevice = (device: MediaDeviceInfo) => {
emits('selectDevice', device);
audioDropDownVisible.value = false;
};
const handleClickIcon = (name: string) => {
if (name === 'hang-up') {
modalVisible.value = true;
}
emits('controlMenuChange', name);
};
const closeModal = () => {
modalVisible.value = false;
};
</script>
<template>
<div class="video-control-menu ml-[50%] flex translate-x-[-50%]">
<div class="control-camera flex">
<button
class="btn-circle btn"
:disabled="menuDisabled"
@click="handleClickIcon('audio')"
>
<svg-icon
name="audio"
:color="
props.activeControlMenu.includes('audio') ? '#2F54EB' : '#707070'
"
class="h-6 w-6"
/>
</button>
<Dropdown v-model:visible="audioDropDownVisible">
<svg-icon
name="up"
class="h-4 w-4 cursor-pointer"
color="#707070"
@click="audioDropDownVisible = true"
/>
<template #content>
<div class="panel">
<SelectList
title="选择麦克风"
:list="audioInputs"
@selected="selectedAudioDevice"
>
<template #default="{ item }">
<div
:class="[
props.selectDevice.audioInput === item.deviceId
? 'text-[#2F54EB]'
: '',
]"
>
{{ item.label }}
</div>
</template>
</SelectList>
<SelectList
title="选择扬声器"
:list="audioOutputs"
@selected="selectedAudioDevice"
>
<template #default="{ item }">
<div
:class="[
props.selectDevice.audioOutput === item.deviceId
? 'text-[#2F54EB]'
: '',
]"
>
{{ item.label }}
</div>
</template>
</SelectList>
</div>
</template>
</Dropdown>
</div>
<MenuAction
:menu-action="menuAction"
:gap="8"
@click-icon="handleClickIcon"
/>
</div>
<Modal v-model:visible="modalVisible" :modal="false">
<template #content> 确定要结束通话吗 </template>
<template #action>
<div class="flex justify-end">
<button class="btn-neutral btn mr-4" @click.prevent="closeModal">
取消
</button>
<button
class="btn-info btn"
@click.prevent="
() => {
nextTick(resetUrl);
}
"
>
确定
</button>
</div>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,42 @@
<script lang="ts" setup>
import MenuAction from '@/components/menu-action.vue';
import { ref, watch } from 'vue';
import { VideoMenuAction } from '@/config';
import { useSwitchMember } from '@/hooks';
import VideoControlMenu from './video-control-menu.vue';
import { useAttrs } from 'vue';
defineOptions({
name: 'VideoControl',
});
const attrs = useAttrs();
const chatActionMenu = ref(VideoMenuAction);
const { switchMember, isLgScreen, open } = useSwitchMember();
watch(
() => open.value,
(v) => {
const findv = chatActionMenu.value.find((item) => item.name === 'member');
if (findv) {
findv.color = v ? '#2F54EB' : undefined;
}
},
{ immediate: true }
);
const handleClickIcon = (name: string) => {
if (name === 'member' && !isLgScreen.value) {
switchMember();
}
};
</script>
<template>
<div class="flex items-center justify-between px-4 py-4">
<VideoControlMenu v-bind="attrs" />
<MenuAction :menu-action="chatActionMenu" @click-icon="handleClickIcon" />
</div>
</template>

View File

@@ -0,0 +1,162 @@
<script lang="ts" setup>
import { useSwitchSiderbar, useSwitchMember, useGetRoomInfo } from '@/hooks';
import { ref } from 'vue';
import { useMediaConnect, useMediaSetting } from './hooks/useVideoCall';
import VideoControl from './video-control.vue';
import { computed } from 'vue';
import { useCreateRoom } from '@/hooks';
import { ToastBox } from '@/components/base';
import { watch } from 'vue';
defineOptions({
name: 'VideoRoom',
});
const { showSiderbar } = useSwitchSiderbar();
showSiderbar.value = 0;
const { open } = useSwitchMember();
const audioInputDevice = ref<MediaDeviceInfo | undefined>();
const speakerRef = ref('');
const otherVideo = ref<{ id: string; ref: any }[]>([]);
const collectOtherVideoRef = (ref: any, id: string) => {
if (!otherVideo.value.find((other) => other.id === id)) {
otherVideo.value.push({ id, ref });
}
};
const toastInfo = ref({
visible: false,
msg: '',
});
const {
startGetMedia,
video,
switchTrackEnable,
videoEnabled,
audioEnabled,
mediaLoaded,
currentAudioInput,
currentAudioOutput,
} = useMediaSetting({
immeately: false,
audio: audioInputDevice,
speaker: speakerRef,
});
// 获取摄像头、麦克风权限
const stream = await startGetMedia().catch(() => {
console.log('没有stream');
});
// 创建房间
useCreateRoom('video', true);
// 视频通话
useMediaConnect(stream!, {
onTrack(track, id) {
otherVideo.value.forEach((item) => {
if (item.id === id) {
item.ref.srcObject = track;
}
});
},
});
const { members, selfInfo } = useGetRoomInfo();
watch(selfInfo, (v) => {
if (v) {
toastInfo.value.visible = true;
toastInfo.value.msg = `您已加入 "${v.roomId}" 房间`;
}
});
const memberwithoutOwner = computed(() =>
members.value.filter((item) => item.id !== selfInfo.value.socketId)
);
const selectDevice = computed(() => ({
audioInput: currentAudioInput.value,
audioOutput: currentAudioOutput.value,
}));
// 当前激活的 菜单
const activeControlMenu = computed<string[]>(() => {
const arr = [];
if (videoEnabled.value) arr.push('camera');
if (audioEnabled.value) arr.push('audio');
return arr;
});
// 切换菜单按钮
const handleMenuChange = (active: string) => {
if (active === 'camera' || active === 'audio') {
const enabled =
active === 'camera' ? videoEnabled.value : audioEnabled.value;
switchTrackEnable(active === 'camera' ? 'video' : 'audio', !enabled);
}
};
// 切换扬声器、麦克风
const deviceChange = (device: MediaDeviceInfo) => {
if (device.kind === 'audioinput') {
audioInputDevice.value = device;
}
if (device.kind === 'audiooutput') {
speakerRef.value = device.deviceId;
}
};
</script>
<template>
<div class="flex h-full">
<div class="flex flex-1 flex-col overflow-hidden">
<video
id="video-call"
ref="video"
style="
height: calc(100% - 80px);
width: 100%;
background-color: #1f1f1f;
"
playsinline
autoplay
></video>
<VideoControl
:selectDevice="selectDevice"
:menuDisabled="!mediaLoaded"
:activeControlMenu="activeControlMenu"
@control-menu-change="handleMenuChange"
@select-device="deviceChange"
/>
</div>
<div v-show="open" class="w-[400px] overflow-auto">
<div class="member-video mt-4 px-2">
<video
v-for="item in memberwithoutOwner"
:key="item.id"
:ref="(ref) => collectOtherVideoRef(ref, item.id || '')"
class="mb-4 h-[200px] w-full"
style="background-color: #1f1f1f"
playsinline
autoplay
/>
</div>
</div>
</div>
<ToastBox
v-model:visible="toastInfo.visible"
horizontal="center"
vertical="top"
type="success"
:msg="toastInfo.msg"
/>
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
import { useCreateRoom } from '@/hooks/useRoom';
import { BackPreviousLevel, BackTitle } from '@/components/back';
import VideoRoom from './video-room.vue';
import { useNow, useDateFormat } from '@vueuse/core';
import { resetUrl } from '@/utils';
import ErrorBoundary from '@/components/error-boundary/index.vue';
import { useErrorCaptured } from '@/hooks/useErrorCaptured';
defineOptions({
name: 'VideoView',
});
const { roomId } = useCreateRoom('video', false);
const handleBackLevel = () => resetUrl();
const formatted = useDateFormat(useNow(), 'YYYY-MM-DD HH:mm:ss');
const errors = useErrorCaptured();
</script>
<template>
<div class="flex w-full flex-col">
<BackPreviousLevel
class="py-2 shadow-md dark:shadow-sm dark:shadow-neutral-600"
@back="handleBackLevel"
>
<BackTitle svg-name="chat" class="flex-1 justify-between">
<div class="cursor-pointer">
{{ roomId }}
</div>
</BackTitle>
<div class="time pr-4 font-bold">{{ formatted }}</div>
</BackPreviousLevel>
<div class="min-h-0 flex-1">
<Suspense>
<VideoRoom />
<template #fallback>
<ErrorBoundary
v-if="errors.length"
:tips-list="[
'请检查网络情况',
'允许浏览器打开摄像头麦克风权限',
'清空浏览器缓存重新打开页面',
]"
/>
<div v-else class="flex h-full w-full items-center justify-center">
<span class="loading loading-dots loading-lg"></span>
</div>
</template>
</Suspense>
</div>
</div>
</template>

View File

@@ -5,7 +5,7 @@ defineOptions({
</script>
<template>
<div>Welcome</div>
<div class="flex items-center justify-center">Welcome</div>
</template>
<stylescoped></stylescoped>

View File

@@ -4,12 +4,28 @@ import eslintPlugin from 'vite-plugin-eslint';
import { resolve } from 'path';
import vueJsx from '@vitejs/plugin-vue-jsx';
import fs from 'fs';
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';
const pathResolve = (path: string) => resolve(__dirname, path);
// http://localhost:9092/api
// https://im.iamtsm.cn/api
// https://vitejs.dev/config/
export default defineConfig({
build: {
outDir: resolve(__dirname, '../../../client_dist/rtc-web'),
minify: 'terser',
emptyOutDir: true,
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
},
},
},
resolve: {
alias: [
{
@@ -18,6 +34,21 @@ export default defineConfig({
},
],
},
server: {
host: '0.0.0.0',
proxy: {
'/api': {
target: 'https://192.168.1.11:9092/api',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
secure: false,
},
},
https: {
key: fs.readFileSync('./src/keys/server.key'),
cert: fs.readFileSync('./src/keys/server.crt'),
},
},
plugins: [
vue(),
vueJsx(),
@@ -27,5 +58,9 @@ export default defineConfig({
// 指定symbolId格式
symbolId: 'icon-[dir]-[name]',
}),
// basicSsl(),
// mkcert({
// source: 'coding',
// }),
],
});

357
client/pnpm-lock.yaml generated
View File

@@ -11,8 +11,10 @@ importers:
'@vitejs/plugin-vue': ^4.2.3
'@vitejs/plugin-vue-jsx': ^3.0.1
'@vueuse/core': ^10.2.0
'@vueuse/router': ^10.2.1
autoprefixer: ^10.4.14
daisyui: ^3.1.6
dayjs: ^1.11.9
eslint: ^8.43.0
eslint-config-prettier: ^8.8.0
eslint-plugin-prettier: ^4.2.1
@@ -23,8 +25,11 @@ importers:
postcss-import: ^15.1.0
prettier: ^2.8.8
prettier-plugin-tailwindcss: ^0.3.0
socket.io-client: 2.3.0
tailwindcss: ^3.3.2
terser: ^5.19.4
typescript: ^5.1.3
vconsole: ^3.15.1
vite: ^4.3.9
vite-plugin-eslint: ^1.8.1
vite-plugin-svg-icons: ^2.0.1
@@ -32,14 +37,21 @@ importers:
vue-eslint-parser: ^9.3.1
vue-router: '4'
vue-tsc: ^1.8.1
vue3-emoji-picker: ^1.1.7
vue3-popper: ^1.5.0
dependencies:
'@types/lodash': 4.14.195
'@vitejs/plugin-vue-jsx': 3.0.1_vite@4.3.9+vue@3.3.4
'@vueuse/core': 10.2.0_vue@3.3.4
'@vueuse/router': 10.2.1_vue-router@4.2.2+vue@3.3.4
dayjs: 1.11.9
lodash: 4.17.21
nanoid: 4.0.2
socket.io-client: 2.3.0
vue: 3.3.4
vue-router: 4.2.2_vue@3.3.4
vue3-emoji-picker: 1.1.7
vue3-popper: 1.5.0_vue@3.3.4
devDependencies:
'@types/node': 20.3.2
'@typescript-eslint/eslint-plugin': 5.60.1_emhilanx7rvvqwlixwztbx5x2m
@@ -56,8 +68,10 @@ importers:
prettier: 2.8.8
prettier-plugin-tailwindcss: 0.3.0_prettier@2.8.8
tailwindcss: 3.3.2
terser: 5.19.4
typescript: 5.1.3
vite: 4.3.9_@types+node@20.3.2
vconsole: 3.15.1
vite: 4.3.9_66pwffvb2axzwdhxmcirxliemu
vite-plugin-eslint: 1.8.1_eslint@8.43.0+vite@4.3.9
vite-plugin-svg-icons: 2.0.1_vite@4.3.9
vue-eslint-parser: 9.3.1_eslint@8.43.0
@@ -336,6 +350,13 @@ packages:
- supports-color
dev: false
/@babel/runtime/7.22.15:
resolution: {integrity: sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==}
engines: {node: '>=6.9.0'}
dependencies:
regenerator-runtime: 0.14.0
dev: true
/@babel/template/7.22.5:
resolution: {integrity: sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==}
engines: {node: '>=6.9.0'}
@@ -620,6 +641,12 @@ packages:
resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==}
engines: {node: '>=6.0.0'}
/@jridgewell/source-map/0.3.5:
resolution: {integrity: sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==}
dependencies:
'@jridgewell/gen-mapping': 0.3.3
'@jridgewell/trace-mapping': 0.3.18
/@jridgewell/sourcemap-codec/1.4.14:
resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==}
@@ -653,6 +680,10 @@ packages:
fastq: 1.15.0
dev: true
/@popperjs/core/2.11.8:
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
dev: false
/@rollup/pluginutils/4.2.1:
resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==}
engines: {node: '>= 8.0.0'}
@@ -842,7 +873,7 @@ packages:
'@babel/core': 7.22.5
'@babel/plugin-transform-typescript': 7.22.5_@babel+core@7.22.5
'@vue/babel-plugin-jsx': 1.1.4_@babel+core@7.22.5
vite: 4.3.9_@types+node@20.3.2
vite: 4.3.9_66pwffvb2axzwdhxmcirxliemu
vue: 3.3.4
transitivePeerDependencies:
- supports-color
@@ -855,7 +886,7 @@ packages:
vite: ^4.0.0
vue: ^3.2.25
dependencies:
vite: 4.3.9_@types+node@20.3.2
vite: 4.3.9_66pwffvb2axzwdhxmcirxliemu
vue: 3.3.4
dev: true
@@ -1021,6 +1052,19 @@ packages:
resolution: {integrity: sha512-IR7Mkq6QSgZ38q/2ZzOt+Zz1OpcEsnwE64WBumDQ+RGKrosFCtUA2zgRrOqDEzPBXrVB+4HhFkwDjQMu0fDBKw==}
dev: false
/@vueuse/router/10.2.1_vue-router@4.2.2+vue@3.3.4:
resolution: {integrity: sha512-H/1T4fLzMmeBNEmcXlbqk6AEp0HQpzf+0eeNJ6fGrs3RWClE2i3nYEFbtxfQeSm/7nZ6nf/UhgahzUQdyMhIwQ==}
peerDependencies:
vue-router: '>=4.0.0-rc.1'
dependencies:
'@vueuse/shared': 10.2.1_vue@3.3.4
vue-demi: 0.14.5_vue@3.3.4
vue-router: 4.2.2_vue@3.3.4
transitivePeerDependencies:
- '@vue/composition-api'
- vue
dev: false
/@vueuse/shared/10.2.0_vue@3.3.4:
resolution: {integrity: sha512-dIeA8+g9Av3H5iF4NXR/sft4V6vys76CpZ6hxwj8eMXybXk2WRl3scSsOVi+kQ9SX38COR7AH7WwY83UcuxbSg==}
dependencies:
@@ -1030,6 +1074,15 @@ packages:
- vue
dev: false
/@vueuse/shared/10.2.1_vue@3.3.4:
resolution: {integrity: sha512-QWHq2bSuGptkcxx4f4M/fBYC3Y8d3M2UYyLsyzoPgEoVzJURQ0oJeWXu79OiLlBb8gTKkqe4mO85T/sf39mmiw==}
dependencies:
vue-demi: 0.14.5_vue@3.3.4
transitivePeerDependencies:
- '@vue/composition-api'
- vue
dev: false
/acorn-jsx/5.3.2_acorn@8.9.0:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1042,7 +1095,10 @@ packages:
resolution: {integrity: sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==}
engines: {node: '>=0.4.0'}
hasBin: true
dev: true
/after/0.8.2:
resolution: {integrity: sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA==}
dev: false
/ajv/6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
@@ -1127,11 +1183,19 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/arraybuffer.slice/0.0.7:
resolution: {integrity: sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==}
dev: false
/assign-symbols/1.0.0:
resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==}
engines: {node: '>=0.10.0'}
dev: true
/async-limiter/1.0.1:
resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==}
dev: false
/atob/2.1.2:
resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==}
engines: {node: '>= 4.5.0'}
@@ -1154,6 +1218,10 @@ packages:
postcss-value-parser: 4.2.0
dev: true
/backo2/1.0.2:
resolution: {integrity: sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==}
dev: false
/balanced-match/1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
dev: true
@@ -1171,6 +1239,22 @@ packages:
pascalcase: 0.1.1
dev: true
/base64-arraybuffer/0.1.4:
resolution: {integrity: sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg==}
engines: {node: '>= 0.6.0'}
dev: false
/base64-arraybuffer/0.1.5:
resolution: {integrity: sha512-437oANT9tP582zZMwSvZGy2nmSeAb8DW2me3y+Uv1Wp2Rulr8Mqlyrv3E7MLxmsiaPSMMDmiDVzgE+e8zlMx9g==}
engines: {node: '>= 0.6.0'}
dev: false
/better-assert/1.0.2:
resolution: {integrity: sha512-bYeph2DFlpK1XmGs6fvlLRUN29QISM3GBuUwSFsMY2XRx4AvC0WNCS57j4c/xGrK2RS24C1w3YoBOsw9fT46tQ==}
dependencies:
callsite: 1.0.0
dev: false
/big.js/5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
dev: true
@@ -1180,6 +1264,10 @@ packages:
engines: {node: '>=8'}
dev: true
/blob/0.0.5:
resolution: {integrity: sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==}
dev: false
/bluebird/3.7.2:
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
dev: true
@@ -1236,6 +1324,9 @@ packages:
node-releases: 2.0.12
update-browserslist-db: 1.0.11_browserslist@4.21.9
/buffer-from/1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
/cache-base/1.0.1:
resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==}
engines: {node: '>=0.10.0'}
@@ -1251,6 +1342,10 @@ packages:
unset-value: 1.0.0
dev: true
/callsite/1.0.0:
resolution: {integrity: sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==}
dev: false
/callsites/3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
@@ -1360,6 +1455,9 @@ packages:
resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==}
dev: true
/commander/2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
/commander/4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
@@ -1370,9 +1468,20 @@ packages:
engines: {node: '>= 10'}
dev: true
/component-bind/1.0.0:
resolution: {integrity: sha512-WZveuKPeKAG9qY+FkYDeADzdHyTYdIboXS59ixDeRJL5ZhxpqUnxSOwop4FQjMsiYm3/Or8cegVbpAHNA7pHxw==}
dev: false
/component-emitter/1.2.1:
resolution: {integrity: sha512-jPatnhd33viNplKjqXKRkGU345p263OIWzDL2wH3LGIGp5Kojo+uXizHmOADRvhGFFTnJqX3jBAKP6vvmSDKcA==}
dev: false
/component-emitter/1.3.0:
resolution: {integrity: sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==}
dev: true
/component-inherit/0.0.3:
resolution: {integrity: sha512-w+LhYREhatpVqTESyGFg3NlP6Iu0kEKUHETY9GoZP/pQyW4mHFZuFWRUCIqVPZ36ueVLtoOEZaAqbCF2RDndaA==}
dev: false
/concat-map/0.0.1:
resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=}
@@ -1387,6 +1496,16 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/copy-text-to-clipboard/3.2.0:
resolution: {integrity: sha512-RnJFp1XR/LOBDckxTib5Qjr/PMfkatD0MUCQgdpqS8MdKiNUzBjAQBEN6oUy+jW7LI93BBG3DtMB2KOOKpGs2Q==}
engines: {node: '>=12'}
dev: true
/core-js/3.32.1:
resolution: {integrity: sha512-lqufgNn9NLnESg5mQeYsxQP5w7wrViSj0jr/kv6ECQiByzQkrn1MKvV0L3acttpDqfQrHLwr2KCMgX5b8X+lyQ==}
requiresBuild: true
dev: true
/cors/2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
@@ -1465,10 +1584,18 @@ packages:
- ts-node
dev: true
/dayjs/1.11.9:
resolution: {integrity: sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==}
dev: false
/de-indent/1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
dev: true
/debounce/1.2.1:
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
dev: false
/debug/2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
@@ -1480,6 +1607,29 @@ packages:
ms: 2.0.0
dev: true
/debug/3.1.0:
resolution: {integrity: sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
ms: 2.0.0
dev: false
/debug/4.1.1:
resolution: {integrity: sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==}
deprecated: Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
ms: 2.1.2
dev: false
/debug/4.3.4:
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
engines: {node: '>=6.0'}
@@ -1603,6 +1753,36 @@ packages:
engines: {node: '>= 4'}
dev: true
/engine.io-client/3.4.4:
resolution: {integrity: sha512-iU4CRr38Fecj8HoZEnFtm2EiKGbYZcPn3cHxqNGl/tmdWRf60KhK+9vE0JeSjgnlS/0oynEfLgKbT9ALpim0sQ==}
dependencies:
component-emitter: 1.3.0
component-inherit: 0.0.3
debug: 3.1.0
engine.io-parser: 2.2.1
has-cors: 1.1.0
indexof: 0.0.1
parseqs: 0.0.6
parseuri: 0.0.6
ws: 6.1.4
xmlhttprequest-ssl: 1.5.5
yeast: 0.1.2
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/engine.io-parser/2.2.1:
resolution: {integrity: sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg==}
dependencies:
after: 0.8.2
arraybuffer.slice: 0.0.7
base64-arraybuffer: 0.1.4
blob: 0.0.5
has-binary2: 1.0.3
dev: false
/entities/1.1.2:
resolution: {integrity: sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==}
dev: true
@@ -2068,6 +2248,16 @@ packages:
ansi-regex: 2.1.1
dev: true
/has-binary2/1.0.3:
resolution: {integrity: sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==}
dependencies:
isarray: 2.0.1
dev: false
/has-cors/1.1.0:
resolution: {integrity: sha512-g5VNKdkFuUuVCP9gYfDJHjK2nqdQJ7aDLTnycnc2+RvsOQbuLdF5pm7vuE5J76SEBIQjs4kQY/BWq74JUmjbXA==}
dev: false
/has-flag/1.0.0:
resolution: {integrity: sha512-DyYHfIYwAJmjAjSSPKANxI8bFY9YtFrgkAfinBojQ8YJTOuOuav64tMUJv584SES4xl74PmuaevIyaLESHdTAA==}
engines: {node: '>=0.10.0'}
@@ -2142,6 +2332,10 @@ packages:
readable-stream: 3.6.2
dev: true
/idb/7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
dev: false
/ignore/5.2.4:
resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==}
engines: {node: '>= 4'}
@@ -2166,6 +2360,10 @@ packages:
engines: {node: '>=0.8.19'}
dev: true
/indexof/0.0.1:
resolution: {integrity: sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==}
dev: false
/inflight/1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
dependencies:
@@ -2302,6 +2500,10 @@ packages:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
dev: true
/isarray/2.0.1:
resolution: {integrity: sha512-c2cu3UxbI+b6kR3fy0nRnAhodsvR9dx7U5+znCOzdj6IfP3upFURTr0Xl5BlQZNKZjEtxrmVyfSdeE3O57smoQ==}
dev: false
/isexe/2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
dev: true
@@ -2540,7 +2742,6 @@ packages:
/ms/2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
dev: true
/ms/2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
@@ -2549,6 +2750,10 @@ packages:
resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==}
dev: true
/mutation-observer/1.0.3:
resolution: {integrity: sha512-M/O/4rF2h776hV7qGMZUH3utZLO/jK7p8rnNgGkjKUw8zCGjRQPxB8z6+5l8+VjRUQ3dNYu4vjqXYLr+U8ZVNA==}
dev: true
/mz/2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
dependencies:
@@ -2619,6 +2824,10 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/object-component/0.0.3:
resolution: {integrity: sha512-S0sN3agnVh2SZNEIGc0N1X4Z5K0JeFbGBrnuZpsxuUh5XLF0BnvWkMjRXo/zGKLd/eghvNIKcx1pQkmUjXIyrA==}
dev: false
/object-copy/0.1.0:
resolution: {integrity: sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==}
engines: {node: '>=0.10.0'}
@@ -2686,6 +2895,26 @@ packages:
callsites: 3.1.0
dev: true
/parseqs/0.0.5:
resolution: {integrity: sha512-B3Nrjw2aL7aI4TDujOzfA4NsEc4u1lVcIRE0xesutH8kjeWF70uk+W5cBlIQx04zUH9NTBvuN36Y9xLRPK6Jjw==}
dependencies:
better-assert: 1.0.2
dev: false
/parseqs/0.0.6:
resolution: {integrity: sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==}
dev: false
/parseuri/0.0.5:
resolution: {integrity: sha512-ijhdxJu6l5Ru12jF0JvzXVPvsC+VibqeaExlNoMhWN6VQ79PGjkmc7oA4W1lp00sFkNyj0fx6ivPLdV51/UMog==}
dependencies:
better-assert: 1.0.2
dev: false
/parseuri/0.0.6:
resolution: {integrity: sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==}
dev: false
/pascalcase/0.1.1:
resolution: {integrity: sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==}
engines: {node: '>=0.10.0'}
@@ -2976,6 +3205,10 @@ packages:
picomatch: 2.3.1
dev: true
/regenerator-runtime/0.14.0:
resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==}
dev: true
/regex-not/1.0.2:
resolution: {integrity: sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==}
engines: {node: '>=0.10.0'}
@@ -3133,6 +3366,39 @@ packages:
- supports-color
dev: true
/socket.io-client/2.3.0:
resolution: {integrity: sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==}
dependencies:
backo2: 1.0.2
base64-arraybuffer: 0.1.5
component-bind: 1.0.0
component-emitter: 1.2.1
debug: 4.1.1
engine.io-client: 3.4.4
has-binary2: 1.0.3
has-cors: 1.1.0
indexof: 0.0.1
object-component: 0.0.3
parseqs: 0.0.5
parseuri: 0.0.5
socket.io-parser: 3.3.3
to-array: 0.1.4
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/socket.io-parser/3.3.3:
resolution: {integrity: sha512-qOg87q1PMWWTeO01768Yh9ogn7chB9zkKtQnya41Y355S0UmpXgpcrFwAgjYJxu9BdKug5r5e9YtVSeWhKBUZg==}
dependencies:
component-emitter: 1.3.0
debug: 3.1.0
isarray: 2.0.1
transitivePeerDependencies:
- supports-color
dev: false
/source-map-js/1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
@@ -3148,6 +3414,12 @@ packages:
urix: 0.1.0
dev: true
/source-map-support/0.5.21:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
dependencies:
buffer-from: 1.1.2
source-map: 0.6.1
/source-map-url/0.4.1:
resolution: {integrity: sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==}
deprecated: See https://github.com/lydell/source-map-url#deprecated
@@ -3161,7 +3433,6 @@ packages:
/source-map/0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
dev: true
/split-string/3.1.0:
resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==}
@@ -3328,6 +3599,16 @@ packages:
- ts-node
dev: true
/terser/5.19.4:
resolution: {integrity: sha512-6p1DjHeuluwxDXcuT9VR8p64klWJKo1ILiy19s6C9+0Bh2+NWTX6nD9EPppiER4ICkHDVB1RkVpin/YW2nQn/g==}
engines: {node: '>=10'}
hasBin: true
dependencies:
'@jridgewell/source-map': 0.3.5
acorn: 8.9.0
commander: 2.20.3
source-map-support: 0.5.21
/text-table/0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true
@@ -3345,6 +3626,10 @@ packages:
any-promise: 1.3.0
dev: true
/to-array/0.1.4:
resolution: {integrity: sha512-LhVdShQD/4Mk4zXNroIQZJC+Ap3zgLcDuwEdcmLv9CCO73NWockQDwyUnW/m8VX/EElfL6FcYx7EeutN4HJA6A==}
dev: false
/to-fast-properties/2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'}
@@ -3479,6 +3764,15 @@ packages:
engines: {node: '>= 0.8'}
dev: true
/vconsole/3.15.1:
resolution: {integrity: sha512-KH8XLdrq9T5YHJO/ixrjivHfmF2PC2CdVoK6RWZB4yftMykYIaXY1mxZYAic70vADM54kpMQF+dYmvl5NRNy1g==}
dependencies:
'@babel/runtime': 7.22.15
copy-text-to-clipboard: 3.2.0
core-js: 3.32.1
mutation-observer: 1.0.3
dev: true
/vite-plugin-eslint/1.8.1_eslint@8.43.0+vite@4.3.9:
resolution: {integrity: sha512-PqdMf3Y2fLO9FsNPmMX+//2BF5SF8nEWspZdgl4kSt7UvHDRHVVfHvxsD7ULYzZrJDGRxR81Nq7TOFgwMnUang==}
peerDependencies:
@@ -3489,7 +3783,7 @@ packages:
'@types/eslint': 8.40.2
eslint: 8.43.0
rollup: 2.79.1
vite: 4.3.9_@types+node@20.3.2
vite: 4.3.9_66pwffvb2axzwdhxmcirxliemu
dev: true
/vite-plugin-svg-icons/2.0.1_vite@4.3.9:
@@ -3505,12 +3799,12 @@ packages:
pathe: 0.2.0
svg-baker: 1.7.0
svgo: 2.8.0
vite: 4.3.9_@types+node@20.3.2
vite: 4.3.9_66pwffvb2axzwdhxmcirxliemu
transitivePeerDependencies:
- supports-color
dev: true
/vite/4.3.9_@types+node@20.3.2:
/vite/4.3.9_66pwffvb2axzwdhxmcirxliemu:
resolution: {integrity: sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
@@ -3539,6 +3833,7 @@ packages:
esbuild: 0.17.19
postcss: 8.4.24
rollup: 3.25.3
terser: 5.19.4
optionalDependencies:
fsevents: 2.3.2
@@ -3612,6 +3907,25 @@ packages:
'@vue/server-renderer': 3.3.4_vue@3.3.4
'@vue/shared': 3.3.4
/vue3-emoji-picker/1.1.7:
resolution: {integrity: sha512-dKSI1NyrinYFykllwcOqBB1sw7EHdwQG4tjHYSO+khQkY8Csn4Evn5X2nAdz8Kl8o3P1J0jV4BGwbQ2dVWCxMA==}
dependencies:
'@popperjs/core': 2.11.8
idb: 7.1.1
vue: 3.3.4
dev: false
/vue3-popper/1.5.0_vue@3.3.4:
resolution: {integrity: sha512-xaEnx90YBnlSg5G2yWqm2DHWHg+DB99UVRp4VsyTF0QLXyHrqSuE1Xo5+sG0AQq/lBcrGMlk5NU5xE2MDLKViw==}
engines: {node: '>=12'}
peerDependencies:
vue: ^3.2.20
dependencies:
'@popperjs/core': 2.11.8
debounce: 1.2.1
vue: 3.3.4
dev: false
/which/2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -3629,11 +3943,30 @@ packages:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
dev: true
/ws/6.1.4:
resolution: {integrity: sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ^5.0.2
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
dependencies:
async-limiter: 1.0.1
dev: false
/xml-name-validator/4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
dev: true
/xmlhttprequest-ssl/1.5.5:
resolution: {integrity: sha512-/bFPLUgJrfGUL10AIv4Y7/CUt6so9CLtB/oFxQSHseSDNNCdC6vwwKEqwLN6wNPBg9YWXAiMu8jkf6RPRS/75Q==}
engines: {node: '>=0.4.0'}
dev: false
/yallist/3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
dev: false
@@ -3647,6 +3980,10 @@ packages:
engines: {node: '>= 14'}
dev: true
/yeast/0.1.2:
resolution: {integrity: sha512-8HFIh676uyGYP6wP13R/j6OJ/1HwJ46snpvzE7aHAN3Ryqh2yX6Xox2B4CUmTwwOIzlG3Bs7ocsP5dZH/R1Qbg==}
dev: false
/yocto-queue/0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}