This commit is contained in:
Lazy 2023-10-04 13:57:28 +08:00
parent d3b139327b
commit ccda81b4be
47 changed files with 14944 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

107
.prettierignore Normal file
View File

@ -0,0 +1,107 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
data/
error
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port

4
.prettierrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"printWidth": 100,
"trailingComma": "none"
}

201
LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

2
README.md Normal file
View File

@ -0,0 +1,2 @@
# synctv-web

9
auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
}

38
components.d.ts vendored Normal file
View File

@ -0,0 +1,38 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
DarkModeSwitcher: typeof import('./src/components/DarkModeSwitcher.vue')['default']
Edit: typeof import('./src/components/icons/Edit.vue')['default']
ElCarousel: typeof import('element-plus/es')['ElCarousel']
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElMain: typeof import('element-plus/es')['ElMain']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElTag: typeof import('element-plus/es')['ElTag']
Header: typeof import('./src/components/Header.vue')['default']
Moon: typeof import('./src/components/icons/Moon.vue')['default']
Play: typeof import('./src/components/icons/Play.vue')['default']
Player: typeof import('./src/components/Player.vue')['default']
RoomList: typeof import('./src/components/RoomList.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Sun: typeof import('./src/components/icons/Sun.vue')['default']
Trash: typeof import('./src/components/icons/Trash.vue')['default']
}
}

1
env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

19
index.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="cn">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SyncTV</title>
<link
href="https://font.sec.miui.com/font/css?family=MiSans:400,500,600,700:Chinese_Simplify,Latin,Chinese_Traditional&amp;display=swap"
rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

6352
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "synctv-web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite --port 8085 --strictPort",
"build": "run-p type-check build-only",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@vueuse/core": "^10.4.1",
"artplayer": "^5.0.9",
"artplayer-plugin-danmuku": "^5.0.1",
"axios": "^1.4.0",
"element-plus": "^2.3.8",
"gsap": "^3.12.2",
"less": "^4.1.3",
"less-loader": "^11.1.3",
"mpegts.js": "^1.7.3",
"nprogress": "^0.2.0",
"pinia": "^2.1.4",
"terser": "^5.18.2",
"vue": "^3.3.4",
"vue-router": "^4.2.4"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.2",
"@tsconfig/node18": "^2.0.1",
"@types/node": "^18.16.19",
"@types/nprogress": "^0.2.0",
"@vitejs/plugin-vue": "^4.2.3",
"@vue/eslint-config-prettier": "^7.1.0",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/tsconfig": "^0.4.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.44.0",
"eslint-plugin-vue": "^9.15.1",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.26",
"prettier": "^2.8.8",
"tailwindcss": "^3.3.3",
"typescript": "~5.0.4",
"unplugin-auto-import": "^0.16.6",
"unplugin-vue-components": "^0.25.1",
"vite": "^4.4.2",
"vue-tsc": "^1.8.4"
}
}

5065
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

17
src/App.vue Normal file
View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import Header from "./components/Header.vue";
import { RouterView } from "vue-router";
import { roomStore } from "./stores/room";
const room = roomStore();
localStorage.login === "true" ? (room.login = true) : false;
</script>
<template>
<Header />
<el-container>
<el-main>
<RouterView />
</el-main>
</el-container>
</template>

183
src/assets/global.less Normal file
View File

@ -0,0 +1,183 @@
@private-color: rgb(23, 108, 255);
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
font-family: MiSans;
transition: background 0.5s;
@apply dark:bg-black dark:text-zinc-200;
}
.el-main {
transition: all 0.6s;
}
.l-input {
margin: 10px;
padding: 10px 8px;
border: 2px solid #4b4b4b;
border-radius: 0.5rem;
transition: all 0.3s;
@apply dark:bg-zinc-900;
&:hover {
border: 2px solid @private-color;
}
&:focus {
@apply outline-none;
border: 2px solid @private-color;
box-shadow: 0 0 10px #176cff70;
}
}
.l-input-violet {
@apply p-2 rounded-lg border-2 border-violet-500 bg-violet-50 text-violet-800 dark:bg-violet-950 dark:text-violet-200;
transition: all 0.3s;
&:hover {
@apply border-violet-600;
}
&:focus {
@apply border-violet-500 outline-none shadow-md;
box-shadow: 0 0 10px #c4b5fd;
}
}
.l-input-slate {
@apply p-2 rounded-lg border-2 border-slate-500 bg-slate-100 text-slate-800 dark:bg-gray-800 dark:text-slate-200;
transition: all 0.3s;
&:hover {
@apply border-slate-600;
}
&:focus {
@apply border-slate-500 outline-none shadow-md;
box-shadow: 0 0 10px #94a3b8;
}
}
a {
@apply transition-all duration-500 text-blue-500 hover:text-blue-300;
}
.btn {
padding: 6px 10px;
transition: all 0.3s;
@apply bg-blue-600 text-white cursor-pointer border-2 border-solid rounded-lg dark:border-blue-900;
&:hover {
box-shadow: 0 0 10px #176cff70;
border: 2px solid @private-color;
padding: 6px 15px;
color: @private-color;
background-color: rgba(23, 108, 255, 0.16);
}
&:disabled {
cursor: not-allowed;
filter: grayscale(0.2);
&:hover {
padding: 6px 10px;
}
}
}
.btn-dense {
padding: 4px 8px;
@apply text-sm;
&:hover {
padding: 5px 9px;
}
}
.btn-success {
@apply bg-green-600 dark:border-green-900;
&:hover {
@apply border-green-600 text-green-600 bg-green-100 shadow-green-500 shadow-lg dark:bg-green-950 dark:border-green-800;
box-shadow: 0 0 10px #8cebaf;
}
}
.btn-warning {
@apply bg-yellow-500 dark:border-yellow-800;
&:hover {
@apply border-yellow-500 text-yellow-600 bg-yellow-100 shadow-yellow-500 shadow-lg dark:bg-[#493c08] dark:backdrop-blur-sm;
box-shadow: 0 0 10px #facc15;
}
}
.btn-error {
@apply bg-red-500 dark:border-red-800;
&:hover {
@apply border-red-500 text-red-600 bg-red-100 shadow-red-500 shadow-lg dark:bg-[#4b0000];
box-shadow: 0 0 10px #ef4444;
}
}
// 通知样式
#notifyBox {
position: absolute;
right: 15px;
top: 15px;
width: 233px;
.notifyyy {
background-color: #176cff;
margin: 10px;
}
}
// 卡片
.card {
border-radius: 1.3rem;
transition: all 0.5s;
@apply bg-gray-100 dark:bg-zinc-900;
.card-title {
@apply pl-5 p-4 text-xl font-bold;
}
.card-body {
@apply px-5;
}
.card-footer {
@apply p-5 flex justify-end;
}
}
// 播放器
.artplayer-app {
aspect-ratio: 16/9;
}
.art-video-player {
height: 100% !important;
}
.art-danmuku-emitter,
.art-layer-danmuku-emitter {
display: none !important;
}
.art-video-player {
margin-bottom: 0 !important;
}
@media (max-width: 640px) {
.el-main {
padding: 0 !important;
.el-row {
margin-left: 0 !important;
margin-right: 0 !important;
.el-col {
padding-left: 0 !important;
padding-right: 0 !important;
}
}
}
.art-control-fullscreenWeb {
display: none !important;
}
}

135
src/assets/reset.css Normal file
View File

@ -0,0 +1,135 @@
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
}
body {
line-height: 1;
}
ol,
ul {
list-style: none;
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
a {
text-decoration: none;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import SunIcon from "./icons/Sun.vue";
import { roomStore } from "@/stores/room";
const room = roomStore();
if (
localStorage.darkMode === "true" ||
(!("darkMode" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
document.documentElement.classList.add("dark");
room.isDarkMode = true;
} else {
document.documentElement.classList.remove("dark");
room.isDarkMode = false;
}
const toggleDarkMode = () => {
localStorage.darkMode = room.isDarkMode = !room.isDarkMode;
room.isDarkMode
? document.documentElement.classList.add("dark")
: document.documentElement.classList.remove("dark");
};
</script>
<template>
<span @click="toggleDarkMode" class="cursor-pointer">
<SunIcon v-if="!room.isDarkMode" width="18" height="18" color="#000" />
<MoonIcon v-else width="18" height="18" color="#e4e4e7" />
</span>
</template>

164
src/components/Header.vue Normal file
View File

@ -0,0 +1,164 @@
<script setup lang="ts">
import { ref } from "vue";
import { RouterLink } from "vue-router";
import { roomStore } from "@/stores/room";
import DarkModeSwitcher from "@/components/DarkModeSwitcher.vue";
const room = roomStore();
const mobileMenu = ref(false);
</script>
<template>
<header class="bg-gray-50 h-16 dark:bg-zinc-900 dark:text-zinc-100">
<nav
class="flex mx-auto max-w-7xl items-center justify-between lg:px-8 p-4 lg:p-5 px-6"
>
<div class="flex lg:flex-1">
<span class="-m-1.5 p-1.5 font-bold"> SyncTV </span>
</div>
<div class="flex lg:hidden">
<span class="p-2 mr-2">
<DarkModeSwitcher />
</span>
<button
type="button"
class="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-700 dark:text-zinc-100"
@click="mobileMenu = true"
>
<span class="sr-only">打开菜单</span>
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
</div>
<div class="hidden lg:flex lg:gap-x-12">
<RouterLink to="/"> &nbsp;&nbsp;&nbsp;&nbsp; </RouterLink>
<RouterLink to="/joinRoom"> 加入房间 </RouterLink>
<RouterLink to="/createRoom"> 创建房间 </RouterLink>
<RouterLink to="/cinema" v-if="room.login">
&nbsp;&nbsp;&nbsp;&nbsp;
</RouterLink>
</div>
<div class="hidden lg:flex lg:flex-1 lg:justify-end">
<DarkModeSwitcher />
</div>
</nav>
<!-- 移动端菜单 -->
<transition name="slide-to-bottom">
<div
class="fixed inset-y-0 right-0 z-[100] w-full overflow-y-auto bg-white px-6 py-5 sm:max-w-sm sm:ring-1 sm:ring-gray-900/10 dark:bg-zinc-800"
v-show="mobileMenu"
>
<div class="flex items-center justify-between">
<span class="-m-1.5 p-1.5 font-bold"> SyncTV </span>
<button
type="button"
class="-m-2.5 rounded-md p-2.5 text-gray-600"
@click="mobileMenu = false"
>
<span class="sr-only">关闭菜单</span>
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="mt-6 flow-root">
<div class="-my-6 divide-y divide-gray-500/10">
<div class="space-y-2 py-6 moblie-menu">
<RouterLink to="/" @click="mobileMenu = false">
&nbsp;&nbsp;&nbsp;&nbsp;
</RouterLink>
<RouterLink to="/joinRoom" @click="mobileMenu = false">
加入房间
</RouterLink>
<RouterLink to="/createRoom" @click="mobileMenu = false">
创建房间
</RouterLink>
<RouterLink
to="/cinema"
@click="mobileMenu = false"
v-if="room.login"
>
&nbsp;&nbsp;&nbsp;&nbsp;
</RouterLink>
</div>
<!-- <div class="py-6">
<a href="javascript::">关于此项目</a>
</div> -->
</div>
</div>
</div>
</transition>
</header>
</template>
<style scoped lang="less">
header {
transition: background 0.5s;
nav {
a {
@apply text-sm font-normal leading-6 text-zinc-600 dark:text-ellipsis dark:text-zinc-200;
&:hover {
@apply text-blue-600 dark:text-sky-500;
}
&.router-link-exact-active {
@apply font-medium text-blue-700 dark:text-sky-400;
}
}
}
.moblie-menu {
a {
@apply -mx-3 block rounded-lg px-3 py-2 text-base font-normal leading-7 text-gray-900 dark:text-zinc-200;
&:hover,
&.router-link-exact-active {
@apply bg-gray-100 font-semibold dark:text-zinc-200 dark:bg-zinc-700;
}
}
}
}
//
.slide-to-bottom-enter-from,
.slide-to-bottom-leave-to {
transform: translateY(-100%);
}
.slide-to-bottom-enter-to,
.slide-to-bottom-leave-from {
transform: translateY(0%);
}
.slide-to-bottom-enter-active,
.slide-to-bottom-leave-active {
transition: all 0.6s ease;
}
</style>

251
src/components/Player.vue Normal file
View File

@ -0,0 +1,251 @@
<script setup lang="ts">
import { useDebounceFn } from "@vueuse/core";
import { roomStore } from "@/stores/room";
import Artplayer from "artplayer";
import type { Option } from "artplayer/types/option";
import { onMounted, onBeforeUnmount, ref, watch } from "vue";
import type { PropType } from "vue";
import artplayerPluginDanmuku from "artplayer-plugin-danmuku";
import mpegts from "mpegts.js";
const room = roomStore();
const artplayer = ref<HTMLDivElement>();
let art: Artplayer;
interface options {
url: string;
}
const Props = defineProps({
options: {
type: Object as PropType<options>,
required: true
}
});
const Emits = defineEmits(["get-instance", "set-player-status", "ws-send"]);
//
// watch(
// () => room.currentMovie,
// () => {
// const jsonData = room.currentMovie;
// setTimeout(() => {
// if (jsonData.url === "") {
// art.switchUrl("https://live.lazy.ink/hd.mp4");
// localStorage.getItem("dev") === "114514" && console.log("");
// } else {
// localStorage.getItem("dev") === "114514" &&
// console.log("", jsonData.url);
// art.option.type = "";
// if (jsonData.live) {
// art.option.type = "flv";
// jsonData.url = `${window.location.origin}/api/live/${jsonData.url}.flv`;
// }
// art.switchUrl(jsonData.url).catch((err) => {
// ElMessage.error("");
// console.error("", err);
// });
// }
// }, 233);
// }
// );
//
watch(
() => room.danmuku,
() => {
art.plugins.artplayerPluginDanmuku.emit(room.danmuku);
}
);
const playFlv = (player: HTMLMediaElement, url: string, art: any) => {
if (mpegts.isSupported()) {
const flv = mpegts.createPlayer(
{ type: "flv", url },
{
headers: {
Authorization: "Bearer " + localStorage.token
}
}
);
flv.attachMediaElement(player);
flv.load();
art.flv = flv;
art.on("destroy", () => flv.destroy());
} else {
art.notice.show = "Unsupported playback format: flv";
}
};
onMounted(() => {
const option: Option = {
container: artplayer.value!,
volume: 0, //
autoplay: false, //
autoSize: false, //
autoMini: false,
theme: "#00a1d6",
loop: false, //
flip: true, //
playbackRate: true, //
aspectRatio: true, //
screenshot: false, //
setting: true,
hotkey: true, //
pip: true, //
mutex: true, //
fullscreen: true, //
fullscreenWeb: true, //
subtitleOffset: false, //
miniProgressBar: true, // ,
playsInline: true, // 使 playsInline
lock: true, //
fastForward: false, //
autoPlayback: false, // 使
autoOrientation: true, //
airplay: false, //
// isLive: room.currentMovie.live,
plugins: [
artplayerPluginDanmuku({
//
danmuku: [],
speed: 4
})
],
// type: 'm3u8',
...Props.options,
// url: "",
// type: "flv",
customType: {
flv: playFlv
}
};
art = new Artplayer(option);
const isPlaying = ref(false);
const onReady = () => {
watch(
() => room.currentMovieStatus.playing,
() => {
//
if (!room.currentMovie.live) {
if (room.currentMovieStatus.playing === isPlaying.value) return;
room.currentMovieStatus.playing ? art.play() : art.pause();
}
}
);
watch(
() => room.currentMovieStatus.seek,
() => {
localStorage.getItem("dev") === "114514" &&
console.log("seek变了", room.currentMovieStatus.seek);
if (!room.currentMovie.live)
room.currentMovieStatus.seek === 0 ? false : (art.seek = room.currentMovieStatus.seek);
}
);
watch(
() => room.currentMovieStatus.rate,
() => {
localStorage.getItem("dev") === "114514" &&
console.log("rate变了", room.currentMovieStatus.rate);
if (!room.currentMovie.live) {
room.currentMovieStatus.rate === art.playbackRate
? void 0
: (art.playbackRate = room.currentMovieStatus.rate);
}
}
);
setTimeout(() => {
if (!room.currentMovie.live) {
art.seek = room.currentMovieStatus.seek;
room.currentMovieStatus.playing ? art.play() : art.pause();
console.log("seek同步成功:", room.currentMovieStatus.seek);
}
}, 100);
localStorage.getItem("dev") === "114514" && console.log("art.seek:", art.currentTime);
localStorage.getItem("dev") === "114514" &&
console.log("room.seek:", room.currentMovieStatus.seek);
Emits("ws-send", "PLAYER视频已就绪");
// art.off('ready', onReady);
//
const vPlayAndPause = useDebounceFn((type: number) => {
if (!room.currentMovie.live)
Emits(
"set-player-status",
JSON.stringify({
Type: type,
Seek: art.currentTime,
Rate: art.playbackRate
})
);
}, 1000);
art.on("play", () => {
vPlayAndPause(3);
isPlaying.value = true;
localStorage.getItem("dev") === "114514" && console.log("视频播放,seek:", art.currentTime);
});
//
art.on("pause", () => {
vPlayAndPause(4);
isPlaying.value = false;
localStorage.getItem("dev") === "114514" &&
console.log("视频暂停中seek:", art.currentTime);
});
//
const debouncedFn = useDebounceFn((currentTime: number) => {
if (!room.currentMovie.live)
Emits(
"set-player-status",
JSON.stringify({
Type: 9,
Seek: currentTime,
Rate: art.playbackRate
})
);
localStorage.getItem("dev") === "114514" && console.log("视频空降,:", art.currentTime);
}, 1000);
art.on("seek", (currentTime) => {
debouncedFn(currentTime);
});
//
art.on("video:ratechange", () => {
console.log(art.playbackRate);
isPlaying.value ? vPlayAndPause(3) : vPlayAndPause(4);
});
};
art.on("ready", onReady);
Emits("get-instance", art);
});
onBeforeUnmount(() => {
// art.destroy();
});
</script>
<template>
<div class="artplayer-app" ref="artplayer"></div>
</template>
<style></style>

203
src/components/RoomList.vue Normal file
View File

@ -0,0 +1,203 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import axios, { type AxiosResponse } from "axios";
import { ElNotification } from "element-plus";
import { roomStore } from "@/stores/room";
import router from "@/router";
import { roomListApi, joinRoomApi } from "@/services/apis/room";
import type { RoomList } from "@/types/Room";
import JoinRoom from "@/views/JoinRoom.vue";
const room = roomStore();
const __roomList = ref<RoomList[]>([
// { roomid: "1", peoplenum: 0, needpassword: false },
// { roomid: "OPPPPPPPPPPPPPPPPPPPPPPPPPPP", peoplenum: 10, needpassword: false },
// { roomid: "1asdsadasdz", peoplenum: 114514, needpassword: true },
// { roomid: "1sdfgdgscv", peoplenum: 114114514514, needpassword: false },
// { roomid: "ewadfds1", peoplenum: 114514, needpassword: true },
// { roomid: "asdfsaddf1", peoplenum: 114114514114514514, needpassword: false },
// { roomid: "1fadfsf", peoplenum: 114514, needpassword: true },
// { roomid: "sdxccvrsd1", peoplenum: 114114514514, needpassword: false },
// { roomid: "fsddfsd1", peoplenum: 114114514114514514, needpassword: false },
// { roomid: "sfdsff1", peoplenum: 114514, needpassword: false },
// { roomid: "cdc1", peoplenum: 114514, needpassword: false },
// { roomid: "aDS1", peoplenum: 0, needpassword: true },
// { roomid: "df", peoplenum: 0, needpassword: true },
// { roomid: "sdaXdasd1", peoplenum: 0, needpassword: false },
// { roomid: "3WAQsdwq1", peoplenum: 0, needpassword: false },
// { roomid: "asdsdAD1", peoplenum: 0, needpassword: false },
// { roomid: "ad1", peoplenum: 0, needpassword: false },
// { roomid: "aDxasds1", peoplenum: 0, needpassword: false },
// { roomid: "adsds1", peoplenum: 0, needpassword: false },
// { roomid: "SADDASDS1", peoplenum: 0, needpassword: false }
]);
const JoinRoomDialog = ref(false);
const formData = ref({
// uname: "",
// RoomID: "",
// password: "",
// hasPwd: false
roomId: "",
password: "",
username: localStorage.getItem("uname") || "",
userPassword: localStorage.getItem("uPasswd") || ""
});
const openJoinRoomDialog = (item: RoomList) => {
// if (!hasPwd && hasUname) return joinRoom(localStorage.uname, RoomID, "");
formData.value.roomId = item.roomId;
JoinRoomDialog.value = true;
};
const { state: roomList, execute: reqRoomList } = roomListApi();
const totalItems = ref(0);
const currentPage = ref(1);
const pageSize = ref(10);
const sort = ref("peopleNum");
const order = ref("desc");
const getRoomList = async (showMsg = false) => {
__roomList.value = [];
try {
await reqRoomList({
params: {
page: currentPage.value,
max: pageSize.value,
sort: sort.value,
order: order.value
}
});
if (roomList.value && roomList.value.list) {
totalItems.value = roomList.value.total;
for (let i = 0; i < roomList.value.list.length; i++) {
__roomList.value.push(roomList.value.list[i]);
}
}
showMsg &&
ElNotification({
title: `更新列表成功`,
type: "success"
});
} catch (err: any) {
console.error(err.message);
ElNotification({
title: "错误",
message: err.response.data.error || err.message,
type: "error"
});
}
};
onMounted(() => {
getRoomList();
});
</script>
<template>
<div class="card mx-auto lg:w-9/12 max-sm:rounded-none">
<div class="card-title flex flex-wrap justify-between">
<div>房间列表{{ __roomList.length }}</div>
<div class="text-base">
排序方式<el-select
v-model="sort"
class="m-2"
placeholder="排序方式"
@change="getRoomList(false)"
>
<el-option label="房间ID" value="roomId" />
<el-option label="房间人数" value="peopleNum" />
<el-option label="创建人首字母" value="creator" />
<el-option label="创建时间" value="createAt" />
<el-option label="是否有密码" value="needPassword" />
</el-select>
<button
class="btn btn-dense"
@click="
order === 'desc' ? (order = 'asc') : (order = 'desc');
getRoomList();
"
>
{{ order === "asc" ? "👆" : "👇" }}
</button>
</div>
</div>
<div class="card-body flex flex-wrap justify-center">
<el-empty v-if="__roomList.length === 0" description="无房间,去创建一个吧~" />
<div
v-else
v-for="item in __roomList"
:key="item.roomId"
class="flex flex-wrap m-2 rounded-lg bg-stone-50 hover:bg-white transition-all dark:bg-zinc-800 hover:dark:bg-neutral-800 max-w-[220px]"
>
<div class="overflow-hidden text-ellipsis m-auto p-2 w-full">
<b class="block text-base font-semibold truncate"> {{ item["roomId"] }}</b>
</div>
<div class="text-sm p-2">
<div class="inline mr-2">
在线人数<span :class="item.peopleNum > 0 ? 'text-green-500' : 'text-red-500'">{{
item["peopleNum"]
}}</span>
</div>
<div class="inline">创建者{{ item.creator }}</div>
<hr />
<div>创建时间{{ new Date(item.createAt).toLocaleString() }}</div>
</div>
<div class="flex p-2 w-full justify-between items-center">
<el-tag disabled :type="item.needPassword ? 'danger' : 'success'">
{{ item.needPassword ? "有密码" : "无密码" }}
</el-tag>
<button class="btn btn-dense" @click="openJoinRoomDialog(item)">
加入房间
<PlayIcon class="inline-block" width="18px" />
</button>
</div>
</div>
</div>
<div class="card-footer justify-between flex-wrap overflow-hidden">
<button class="btn btn-success max-sm:mb-4" @click="getRoomList(true)">更新列表</button>
<el-pagination
v-if="__roomList.length > 0"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:pager-count="4"
layout="total, sizes, prev, pager, next, jumper"
:total="totalItems"
@size-change="getRoomList(false)"
@current-change="getRoomList(false)"
/>
</div>
</div>
<el-dialog
v-model="JoinRoomDialog"
:title="'加入房间 ' + formData.roomId"
class="rounded-lg dark:bg-zinc-800 w-[443px] max-sm:w-[90%]"
>
<!-- <el-form
label-position="top"
ref="form"
@submit.prevent="joinRoom(formData.uname, formData.RoomID, formData.password)"
>
<el-form-item label="设置一个用户名:" v-if="!hasUname">
<input type="text" class="l-input m-0 p-0 pl-2 w-full" v-model="formData.uname" />
</el-form-item>
<el-form-item label="房间密码:" v-if="formData.hasPwd">
<input type="text" class="l-input m-0 p-0 pl-2 w-full" v-model="formData.password" />
</el-form-item>
</el-form> -->
<JoinRoom :item="formData" />
<!-- <template #footer>
<button
class="btn btn-success"
@click="joinRoom(formData.uname, formData.RoomID, formData.password)"
>
加入
</button>
</template> -->
</el-dialog>
</template>

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
width?: string;
height?: string;
color?: string;
}>(),
{
width: "15",
height: "15",
color: "#ffff",
}
);
</script>
<template>
<svg
:width="width"
:height="height"
:fill="color"
viewBox="0 0 15 15"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.1464 1.14645C12.3417 0.951184 12.6583 0.951184 12.8535 1.14645L14.8535 3.14645C15.0488 3.34171 15.0488 3.65829 14.8535 3.85355L10.9109 7.79618C10.8349 7.87218 10.7471 7.93543 10.651 7.9835L6.72359 9.94721C6.53109 10.0435 6.29861 10.0057 6.14643 9.85355C5.99425 9.70137 5.95652 9.46889 6.05277 9.27639L8.01648 5.34897C8.06455 5.25283 8.1278 5.16507 8.2038 5.08907L12.1464 1.14645ZM12.5 2.20711L8.91091 5.79618L7.87266 7.87267L8.12731 8.12732L10.2038 7.08907L13.7929 3.5L12.5 2.20711ZM9.99998 2L8.99998 3H4.9C4.47171 3 4.18056 3.00039 3.95552 3.01877C3.73631 3.03668 3.62421 3.06915 3.54601 3.10899C3.35785 3.20487 3.20487 3.35785 3.10899 3.54601C3.06915 3.62421 3.03669 3.73631 3.01878 3.95552C3.00039 4.18056 3 4.47171 3 4.9V11.1C3 11.5283 3.00039 11.8194 3.01878 12.0445C3.03669 12.2637 3.06915 12.3758 3.10899 12.454C3.20487 12.6422 3.35785 12.7951 3.54601 12.891C3.62421 12.9309 3.73631 12.9633 3.95552 12.9812C4.18056 12.9996 4.47171 13 4.9 13H11.1C11.5283 13 11.8194 12.9996 12.0445 12.9812C12.2637 12.9633 12.3758 12.9309 12.454 12.891C12.6422 12.7951 12.7951 12.6422 12.891 12.454C12.9309 12.3758 12.9633 12.2637 12.9812 12.0445C12.9996 11.8194 13 11.5283 13 11.1V6.99998L14 5.99998V11.1V11.1207C14 11.5231 14 11.8553 13.9779 12.1259C13.9549 12.407 13.9057 12.6653 13.782 12.908C13.5903 13.2843 13.2843 13.5903 12.908 13.782C12.6653 13.9057 12.407 13.9549 12.1259 13.9779C11.8553 14 11.5231 14 11.1207 14H11.1H4.9H4.87934C4.47686 14 4.14468 14 3.87409 13.9779C3.59304 13.9549 3.33469 13.9057 3.09202 13.782C2.7157 13.5903 2.40973 13.2843 2.21799 12.908C2.09434 12.6653 2.04506 12.407 2.0221 12.1259C1.99999 11.8553 1.99999 11.5231 2 11.1207V11.1206V11.1V4.9V4.87935V4.87932V4.87931C1.99999 4.47685 1.99999 4.14468 2.0221 3.87409C2.04506 3.59304 2.09434 3.33469 2.21799 3.09202C2.40973 2.71569 2.7157 2.40973 3.09202 2.21799C3.33469 2.09434 3.59304 2.04506 3.87409 2.0221C4.14468 1.99999 4.47685 1.99999 4.87932 2H4.87935H4.9H9.99998Z"
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
></path>
</svg>
</template>

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
width?: string;
height?: string;
color?: string;
}>(),
{
width: "15",
height: "15",
color: "#ffff",
}
);
</script>
<template>
<svg
:width="width"
:height="height"
:fill="color"
viewBox="0 0 15 15"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.89998 0.499976C2.89998 0.279062 2.72089 0.0999756 2.49998 0.0999756C2.27906 0.0999756 2.09998 0.279062 2.09998 0.499976V1.09998H1.49998C1.27906 1.09998 1.09998 1.27906 1.09998 1.49998C1.09998 1.72089 1.27906 1.89998 1.49998 1.89998H2.09998V2.49998C2.09998 2.72089 2.27906 2.89998 2.49998 2.89998C2.72089 2.89998 2.89998 2.72089 2.89998 2.49998V1.89998H3.49998C3.72089 1.89998 3.89998 1.72089 3.89998 1.49998C3.89998 1.27906 3.72089 1.09998 3.49998 1.09998H2.89998V0.499976ZM5.89998 3.49998C5.89998 3.27906 5.72089 3.09998 5.49998 3.09998C5.27906 3.09998 5.09998 3.27906 5.09998 3.49998V4.09998H4.49998C4.27906 4.09998 4.09998 4.27906 4.09998 4.49998C4.09998 4.72089 4.27906 4.89998 4.49998 4.89998H5.09998V5.49998C5.09998 5.72089 5.27906 5.89998 5.49998 5.89998C5.72089 5.89998 5.89998 5.72089 5.89998 5.49998V4.89998H6.49998C6.72089 4.89998 6.89998 4.72089 6.89998 4.49998C6.89998 4.27906 6.72089 4.09998 6.49998 4.09998H5.89998V3.49998ZM1.89998 6.49998C1.89998 6.27906 1.72089 6.09998 1.49998 6.09998C1.27906 6.09998 1.09998 6.27906 1.09998 6.49998V7.09998H0.499976C0.279062 7.09998 0.0999756 7.27906 0.0999756 7.49998C0.0999756 7.72089 0.279062 7.89998 0.499976 7.89998H1.09998V8.49998C1.09998 8.72089 1.27906 8.89997 1.49998 8.89997C1.72089 8.89997 1.89998 8.72089 1.89998 8.49998V7.89998H2.49998C2.72089 7.89998 2.89998 7.72089 2.89998 7.49998C2.89998 7.27906 2.72089 7.09998 2.49998 7.09998H1.89998V6.49998ZM8.54406 0.98184L8.24618 0.941586C8.03275 0.917676 7.90692 1.1655 8.02936 1.34194C8.17013 1.54479 8.29981 1.75592 8.41754 1.97445C8.91878 2.90485 9.20322 3.96932 9.20322 5.10022C9.20322 8.37201 6.82247 11.0878 3.69887 11.6097C3.45736 11.65 3.20988 11.6772 2.96008 11.6906C2.74563 11.702 2.62729 11.9535 2.77721 12.1072C2.84551 12.1773 2.91535 12.2458 2.98667 12.3128L3.05883 12.3795L3.31883 12.6045L3.50684 12.7532L3.62796 12.8433L3.81491 12.9742L3.99079 13.089C4.11175 13.1651 4.23536 13.2375 4.36157 13.3059L4.62496 13.4412L4.88553 13.5607L5.18837 13.6828L5.43169 13.7686C5.56564 13.8128 5.70149 13.8529 5.83857 13.8885C5.94262 13.9155 6.04767 13.9401 6.15405 13.9622C6.27993 13.9883 6.40713 14.0109 6.53544 14.0298L6.85241 14.0685L7.11934 14.0892C7.24637 14.0965 7.37436 14.1002 7.50322 14.1002C11.1483 14.1002 14.1032 11.1453 14.1032 7.50023C14.1032 7.25044 14.0893 7.00389 14.0623 6.76131L14.0255 6.48407C13.991 6.26083 13.9453 6.04129 13.8891 5.82642C13.8213 5.56709 13.7382 5.31398 13.6409 5.06881L13.5279 4.80132L13.4507 4.63542L13.3766 4.48666C13.2178 4.17773 13.0353 3.88295 12.8312 3.60423L12.6782 3.40352L12.4793 3.16432L12.3157 2.98361L12.1961 2.85951L12.0355 2.70246L11.8134 2.50184L11.4925 2.24191L11.2483 2.06498L10.9562 1.87446L10.6346 1.68894L10.3073 1.52378L10.1938 1.47176L9.95488 1.3706L9.67791 1.2669L9.42566 1.1846L9.10075 1.09489L8.83599 1.03486L8.54406 0.98184ZM10.4032 5.30023C10.4032 4.27588 10.2002 3.29829 9.83244 2.40604C11.7623 3.28995 13.1032 5.23862 13.1032 7.50023C13.1032 10.593 10.596 13.1002 7.50322 13.1002C6.63646 13.1002 5.81597 12.9036 5.08355 12.5522C6.5419 12.0941 7.81081 11.2082 8.74322 10.0416C8.87963 10.2284 9.10028 10.3497 9.34928 10.3497C9.76349 10.3497 10.0993 10.0139 10.0993 9.59971C10.0993 9.24256 9.84965 8.94373 9.51535 8.86816C9.57741 8.75165 9.63653 8.63334 9.6926 8.51332C9.88358 8.63163 10.1088 8.69993 10.35 8.69993C11.0403 8.69993 11.6 8.14028 11.6 7.44993C11.6 6.75976 11.0406 6.20024 10.3505 6.19993C10.3853 5.90487 10.4032 5.60464 10.4032 5.30023Z"
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
></path>
</svg>
</template>

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
width?: string;
height?: string;
color?: string;
}>(),
{
width: "15",
height: "15",
color: "#ffff",
}
);
</script>
<template>
<svg
:width="width"
:height="height"
:fill="color"
viewBox="0 0 15 15"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.24182 2.32181C3.3919 2.23132 3.5784 2.22601 3.73338 2.30781L12.7334 7.05781C12.8974 7.14436 13 7.31457 13 7.5C13 7.68543 12.8974 7.85564 12.7334 7.94219L3.73338 12.6922C3.5784 12.774 3.3919 12.7687 3.24182 12.6782C3.09175 12.5877 3 12.4252 3 12.25V2.75C3 2.57476 3.09175 2.4123 3.24182 2.32181ZM4 3.57925V11.4207L11.4288 7.5L4 3.57925Z"
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
></path>
</svg>
</template>

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
width?: string;
height?: string;
color?: string;
}>(),
{
width: "15",
height: "15",
color: "#ffff",
}
);
</script>
<template>
<svg
:width="width"
:height="height"
:fill="color"
viewBox="0 0 15 15"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.5 0C7.77614 0 8 0.223858 8 0.5V2.5C8 2.77614 7.77614 3 7.5 3C7.22386 3 7 2.77614 7 2.5V0.5C7 0.223858 7.22386 0 7.5 0ZM2.1967 2.1967C2.39196 2.00144 2.70854 2.00144 2.90381 2.1967L4.31802 3.61091C4.51328 3.80617 4.51328 4.12276 4.31802 4.31802C4.12276 4.51328 3.80617 4.51328 3.61091 4.31802L2.1967 2.90381C2.00144 2.70854 2.00144 2.39196 2.1967 2.1967ZM0.5 7C0.223858 7 0 7.22386 0 7.5C0 7.77614 0.223858 8 0.5 8H2.5C2.77614 8 3 7.77614 3 7.5C3 7.22386 2.77614 7 2.5 7H0.5ZM2.1967 12.8033C2.00144 12.608 2.00144 12.2915 2.1967 12.0962L3.61091 10.682C3.80617 10.4867 4.12276 10.4867 4.31802 10.682C4.51328 10.8772 4.51328 11.1938 4.31802 11.3891L2.90381 12.8033C2.70854 12.9986 2.39196 12.9986 2.1967 12.8033ZM12.5 7C12.2239 7 12 7.22386 12 7.5C12 7.77614 12.2239 8 12.5 8H14.5C14.7761 8 15 7.77614 15 7.5C15 7.22386 14.7761 7 14.5 7H12.5ZM10.682 4.31802C10.4867 4.12276 10.4867 3.80617 10.682 3.61091L12.0962 2.1967C12.2915 2.00144 12.608 2.00144 12.8033 2.1967C12.9986 2.39196 12.9986 2.70854 12.8033 2.90381L11.3891 4.31802C11.1938 4.51328 10.8772 4.51328 10.682 4.31802ZM8 12.5C8 12.2239 7.77614 12 7.5 12C7.22386 12 7 12.2239 7 12.5V14.5C7 14.7761 7.22386 15 7.5 15C7.77614 15 8 14.7761 8 14.5V12.5ZM10.682 10.682C10.8772 10.4867 11.1938 10.4867 11.3891 10.682L12.8033 12.0962C12.9986 12.2915 12.9986 12.608 12.8033 12.8033C12.608 12.9986 12.2915 12.9986 12.0962 12.8033L10.682 11.3891C10.4867 11.1938 10.4867 10.8772 10.682 10.682ZM5.5 7.5C5.5 6.39543 6.39543 5.5 7.5 5.5C8.60457 5.5 9.5 6.39543 9.5 7.5C9.5 8.60457 8.60457 9.5 7.5 9.5C6.39543 9.5 5.5 8.60457 5.5 7.5ZM7.5 4.5C5.84315 4.5 4.5 5.84315 4.5 7.5C4.5 9.15685 5.84315 10.5 7.5 10.5C9.15685 10.5 10.5 9.15685 10.5 7.5C10.5 5.84315 9.15685 4.5 7.5 4.5Z"
fill-rule="evenodd"
clip-rule="evenodd"
></path>
</svg>
</template>

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
width?: string;
height?: string;
color?: string;
}>(),
{
width: "15",
height: "15",
color: "#ffff",
}
);
</script>
<template>
<svg
:width="width"
:height="height"
:fill="color"
viewBox="0 0 15 15"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.5 1C5.22386 1 5 1.22386 5 1.5C5 1.77614 5.22386 2 5.5 2H9.5C9.77614 2 10 1.77614 10 1.5C10 1.22386 9.77614 1 9.5 1H5.5ZM3 3.5C3 3.22386 3.22386 3 3.5 3H5H10H11.5C11.7761 3 12 3.22386 12 3.5C12 3.77614 11.7761 4 11.5 4H11V12C11 12.5523 10.5523 13 10 13H5C4.44772 13 4 12.5523 4 12V4L3.5 4C3.22386 4 3 3.77614 3 3.5ZM5 4H10V12H5V4Z"
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
></path>
</svg>
</template>

28
src/main.ts Normal file
View File

@ -0,0 +1,28 @@
import './assets/reset.css'
import './assets/global.less'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import SunIcon from './components/icons/Sun.vue'
import MoonIcon from './components/icons/Moon.vue'
import PlayIcon from './components/icons/Play.vue'
import EditIcon from './components/icons/Edit.vue'
import TrashIcon from './components/icons/Trash.vue'
const app = createApp(App)
app
.component('SunIcon', SunIcon)
.component('MoonIcon', MoonIcon)
.component('PlayIcon', PlayIcon)
.component('EditIcon', EditIcon)
.component('TrashIcon', TrashIcon)
app.use(createPinia())
app.use(router)
app.mount('#app')

51
src/router/index.ts Normal file
View File

@ -0,0 +1,51 @@
import { createRouter, createWebHistory } from "vue-router";
import { start, close } from "@/utils/nprogress";
const Base_Title = "SyncTV";
const router = createRouter({
history: createWebHistory(import.meta.env.VITE_BASEURL),
routes: [
{
path: "/",
name: "home",
component: () => import("../views/HomeView.vue"),
meta: { title: "首页" }
},
{
path: "/createRoom",
name: "createRoom",
component: () => import("../views/CreateRoom.vue"),
meta: { title: "创建房间" }
},
{
path: "/joinRoom",
name: "joinRoom",
component: () => import("../views/JoinRoom.vue"),
meta: { title: "加入房间" }
},
{
path: "/cinema",
name: "cinema",
component: () => import("../views/Cinema.vue"),
meta: { title: "影厅" }
}
],
scrollBehavior(to, from, savedPosition) {
return {
top: 0
};
}
});
router.beforeEach((to: any, from: any, next) => {
start();
window.document.title = Base_Title + " - " + to.meta.title;
next();
});
router.afterEach(() => {
close();
});
export default router;

140
src/services/apis/movie.ts Normal file
View File

@ -0,0 +1,140 @@
import { useDefineApi } from "@/stores/useDefineApi";
import type { BaseMovieInfo, MovieInfo, EditMovieInfo, MovieStatus } from "@/types/Movie";
// 获取影片列表
export const movieListApi = useDefineApi<
{
headers: { Authorization: string };
},
{
current: {
movie: MovieInfo;
status: MovieStatus;
};
movies: MovieInfo[] | [];
total: number;
}
>({
url: "/api/movie/list",
method: "GET"
});
// 添加影片
export const pushMovieApi = useDefineApi<
// request
{
params: {
pos: string;
};
data: BaseMovieInfo;
headers: { Authorization: string };
},
// response
{
id: number;
}
>({
url: "/api/movie/push",
method: "POST"
});
// 编辑影片信息
export const editMovieInfoApi = useDefineApi<
{
data: EditMovieInfo;
headers: { Authorization: string };
},
{}
>({
url: "/api/movie/edit",
method: "POST"
});
// 删除影片
export const delMovieApi = useDefineApi<
{
data: {
ids: Array<number>;
};
headers: { Authorization: string };
},
{}
>({
url: "/api/movie/delete",
method: "POST"
});
// 交换影片位置
export const swapMovieApi = useDefineApi<
{
data: {
id1: number;
id2: number;
};
headers: { Authorization: string };
},
{}
>({
url: "/api/movie/swap",
method: "POST"
});
// 当前影片状态
export const movieStatusApi = useDefineApi<
{
headers: { Authorization: string };
},
{
current: {
movie: MovieInfo;
status: MovieStatus;
};
}
>({
url: "/api/movie/current",
method: "GET"
});
// 更改正在播放的影片
export const changeCurrentMovieApi = useDefineApi<
{
headers: { Authorization: string };
data: {
id: number;
};
},
{}
>({
url: "/api/movie/current",
method: "POST"
});
// 清空影片列表
export const clearMovieListApi = useDefineApi<
{
headers: { Authorization: string };
},
{}
>({
url: "/api/movie/clear",
method: "POST"
});
// 获取直播信息
export const liveInfoApi = useDefineApi<
{
data: {
id: number;
};
headers: { Authorization: string };
},
{
host: string;
port: number;
app: string;
token: string;
}
>({
url: "/api/movie/live/publishKey",
method: "POST"
});

98
src/services/apis/room.ts Normal file
View File

@ -0,0 +1,98 @@
import { useDefineApi } from "@/stores/useDefineApi";
import type { RoomList } from "@/types/Room";
import type { MovieInfo } from "@/types/Movie";
// 房间列表
export const roomListApi = useDefineApi<
// request
{
params: {
page: number;
max: number;
sort: string;
order: string;
};
},
// response 服务器返回的 data: {}里面的内容
{
list: RoomList[] | null;
total: number;
}
>({
url: "/api/room/list",
method: "GET"
});
// 创建房间
export const createRoomApi = useDefineApi<
// request
{
data: {
roomId: string;
password: string;
username: string;
userPassword: string;
hidden: boolean;
};
},
// response 服务器返回的 data: {}里面的内容
{
token: string;
}
>({
url: "/api/room/create",
method: "POST"
});
// 加入房间
export const joinRoomApi = useDefineApi<
// request
{
data: {
roomId: string;
password: string;
username: string;
userPassword: string;
};
params: {
autoNew: boolean;
};
},
// response
{
token: string;
}
>({
url: "/api/room/login",
method: "POST"
});
// 删除房间
export const delRoomApi = useDefineApi<
{
data: {
roomId: string;
};
headers: { Authorization: string };
},
{}
>({
url: "/api/room/delete",
method: "POST"
});
// 更新房间密码
export const updateRoomPasswordApi = useDefineApi<
{
data: {
password: string;
};
headers: { Authorization: string };
},
{
token: string;
}
>({
url: "/api/room/pwd",
method: "POST"
});

62
src/stores/room.ts Normal file
View File

@ -0,0 +1,62 @@
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import type { MovieInfo, MovieStatus } from "@/types/Movie";
export const roomStore = defineStore("roomStore", () => {
const login = ref(false);
const isDarkMode = ref(false);
// 影片列表
const movieList = ref({});
// 设置播放当前影片
const currentMovie = ref<MovieInfo>({
name: "",
live: false,
proxy: false,
url: "",
rtmpSource: false,
type: "",
headers: null,
createAt: Date.now(),
creator: "SYSTEM",
id: 1,
lastEditAt: Date.now()
});
// 当前影片播放状态
const currentMovieStatus = ref<MovieStatus>({
playing: false,
rate: 1,
seek: 0
});
// 是否主动上报
const play = ref(true);
const count = ref(3);
// 在线人数
const peopleNum = ref(1);
const doubleCount = computed(() => count.value * 2);
function increment() {
count.value++;
}
const danmuku = ref({});
return {
count,
isDarkMode,
login,
movieList,
currentMovie,
currentMovieStatus,
play,
doubleCount,
increment,
danmuku,
peopleNum
};
});

View File

@ -0,0 +1,17 @@
import { type AxiosRequestConfig } from "axios";
import { request } from "@/utils/requests";
export const useDefineApi = <P, T>(config2: AxiosRequestConfig) => {
return () => {
const { isLoading, state, isReady, execute } = request<T>(config2);
return {
isLoading,
state,
isReady,
execute: async (config?: P & AxiosRequestConfig) => {
await execute(0, config ? config : {});
return state;
}
};
};
};

31
src/types/Movie.ts Normal file
View File

@ -0,0 +1,31 @@
export interface MovieInfo extends BaseMovieInfo {
createAt: number;
creator: string;
id: number;
lastEditAt: number;
pullKey?: string;
}
export interface BaseMovieInfo {
url: string;
name: string;
live: boolean;
proxy: boolean;
rtmpSource: boolean;
type: string;
headers: null;
}
export interface EditMovieInfo {
id: number;
url: string;
name: string;
type: string;
headers: null;
}
export interface MovieStatus {
playing: boolean;
rate: number;
seek: number;
}

40
src/types/Room.ts Normal file
View File

@ -0,0 +1,40 @@
import type { MovieInfo, MovieStatus } from "./Movie";
export interface RoomList {
roomId: string;
peopleNum: number;
needPassword: boolean;
creator: string;
createAt: number;
}
export interface RoomInfo {}
export interface WsMessage {
type: number;
sender: string;
message: string;
rate: number;
seek: number;
movies: MovieInfo[];
current: {
movie: MovieInfo;
status: MovieStatus;
};
peopleNum: number;
}
export enum WsMessageType {
MESSAGE = 2,
PLAY = 3,
PAUSE = 4,
SEEK = 9,
CURRENT_MOVIE = 10,
PLAY_LIST_UPDATE = 11,
PEOPLE_NUM = 12
}

41
src/utils/notify.ts Normal file
View File

@ -0,0 +1,41 @@
/**
* @author Lazy
* @description
*
*/
class Notify {
private title!: string;
private content!: string;
private status!: string;
constructor() {
let notifyBox = document.createElement("div");
notifyBox.id = "notifyBox";
document.body.appendChild(notifyBox);
}
init(title: string, content: string, status: string) {
this.title = title;
this.content = content;
this.status = status;
this.show();
}
async show() {
let div = document.createElement("div");
let nid = Date.now();
div.className = "notifyyy";
div.setAttribute("nid", String(nid));
div.innerHTML = `
<h2>${this.title}</h2>
<p>${this.content}</p>
`;
document.querySelector("#notifyBox")?.appendChild(div);
setTimeout(() => {
document.querySelector(`div[nid="${nid}"]`)?.remove();
}, 3000);
}
}
export default Notify;

18
src/utils/nprogress.ts Normal file
View File

@ -0,0 +1,18 @@
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
NProgress.configure({
easing: 'ease',
speed: 500,
showSpinner: false,
trickleSpeed: 200,
minimum: 0.2
})
export const start = () => {
NProgress.start()
}
export const close = () => {
NProgress.done()
}

26
src/utils/requests.ts Normal file
View File

@ -0,0 +1,26 @@
import axios, { type AxiosRequestConfig } from "axios";
import { useAsyncState } from "@vueuse/core";
const _req = async <T>(config: AxiosRequestConfig): Promise<T | undefined> => {
const result = await axios(config);
let realData = result.data;
if (realData.data) realData = realData.data;
return realData;
};
export const request = <T>(config: AxiosRequestConfig) => {
return useAsyncState<T | undefined, AxiosRequestConfig[]>(
async (config2) => {
config = Object.assign({}, config, config2);
const result = await _req<T>(config);
return result;
},
undefined,
{
immediate: false,
shallow: false as any,
throwError: true,
resetOnExecute: false
}
);
};

985
src/views/Cinema.vue Normal file
View File

@ -0,0 +1,985 @@
<script setup lang="ts">
import axios, { type AxiosResponse } from "axios";
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useWebSocket, useWindowSize } from "@vueuse/core";
import Player from "@/components/Player.vue";
import ArtPlayer from "artplayer";
import { roomStore } from "@/stores/room";
import { ElNotification, ElMessage } from "element-plus";
import router from "@/router";
import { updateRoomPasswordApi, delRoomApi } from "@/services/apis/room";
import {
movieListApi,
pushMovieApi,
editMovieInfoApi,
delMovieApi,
swapMovieApi,
movieStatusApi,
changeCurrentMovieApi,
clearMovieListApi,
liveInfoApi
} from "@/services/apis/movie";
import type { BaseMovieInfo, MovieInfo, EditMovieInfo, MovieStatus } from "@/types/Movie";
import type { WsMessage } from "@/types/Room";
import { WsMessageType } from "@/types/Room";
const { width: WindowWidth } = useWindowSize();
const room = roomStore();
//
(() => !room.login && router.push("/"))();
//
const roomID = localStorage.roomId;
let password = localStorage.password;
// websocket
const { status, data, send, close } = useWebSocket(`ws://${window.location.host}/api/room/ws`, {
protocols: [localStorage.token],
autoReconnect: {
retries: 3,
delay: 1000,
onFailed() {
ElMessage.error("Websocket 自动重连失败!");
}
}
});
//
const { state: newToken, execute: reqUpdateRoomPasswordApi } = updateRoomPasswordApi();
const changePassword = async () => {
try {
await reqUpdateRoomPasswordApi({
data: {
password: password
},
headers: { Authorization: localStorage.token }
});
ElNotification({
title: "更新成功",
type: "success"
});
if (newToken.value) {
localStorage.setItem("token", newToken.value.token);
localStorage.setItem("password", password);
setInterval(() => {
window.location.reload();
}, 500);
}
} catch (err: any) {
console.error(err);
ElNotification({
title: "更新失败",
message: err.response.data.error || err.message,
type: "error"
});
}
};
//
let isShowPassword = ref(false);
//
const { execute: reqDelRoomApi } = delRoomApi();
const deleteRoom = async () => {
try {
await reqDelRoomApi({
data: {
roomId: localStorage.roomId
},
headers: { Authorization: localStorage.token }
});
ElNotification({
title: "删除成功",
type: "success"
});
setInterval(() => {
localStorage.removeItem("RoomID");
localStorage.removeItem("password");
localStorage.removeItem("login");
localStorage.removeItem("token");
window.location.href = window.location.origin;
}, 500);
} catch (err: any) {
console.error(err);
ElNotification({
title: "删除失败",
message: err.response.data.error || err.message,
type: "error"
});
}
};
//
let movieList = ref<MovieInfo[]>([]);
const { state: _movieList, isLoading: movieListLoading, execute: reqMovieListApi } = movieListApi();
/**
* @argument updateStatus 是否更新当前正在播放的影片包括状态
*/
const getMovieList = async (updateStatus: boolean) => {
try {
await reqMovieListApi({
headers: { Authorization: localStorage.token }
});
if (_movieList.value) {
localStorage.getItem("dev") === "114514" && console.log(_movieList.value);
room.movieList = movieList.value = _movieList.value.movies;
if (updateStatus) {
room.currentMovie = _movieList.value.current.movie;
room.currentMovieStatus = _movieList.value.current.status;
}
}
} catch (err: any) {
localStorage.getItem("dev") === "114514" && console.log(err);
if (err.response.status === 401) {
ElNotification({
title: "身份验证失败,请重新进入房间",
message: err.message,
type: "error"
});
setInterval(() => {
localStorage.removeItem("roomId");
localStorage.removeItem("password");
localStorage.removeItem("login");
localStorage.removeItem("token");
window.location.href = window.location.origin;
}, 500);
}
ElNotification({
title: "获取影片列表失败",
message: err.response.data.error || err.message,
type: "error"
});
}
};
//
const { execute: reqClearMovieListApi } = clearMovieListApi();
const clearMovieList = async (id: number) => {
try {
await reqClearMovieListApi({
headers: { Authorization: localStorage.token }
});
ElNotification({
title: "已清空",
type: "success"
});
} catch (err: any) {
console.error(err);
ElNotification({
title: "清除成功",
message: err.response.data.error || err.message,
type: "error"
});
}
};
//
let newMovieInfo = ref<BaseMovieInfo>({
name: "",
live: false,
proxy: false,
url: "",
rtmpSource: false,
type: "",
headers: null
});
//
const liveInfoDialog = ref(false);
const liveInfoForm = ref({
host: "",
port: 0,
app: "",
token: ""
});
const { state: liveInfo, execute: reqLiveInfoApi } = liveInfoApi();
const getLiveInfo = async (id: number) => {
try {
await reqLiveInfoApi({
data: {
id: id
},
headers: { Authorization: localStorage.token }
});
liveInfoDialog.value = true;
if (liveInfo.value) liveInfoForm.value = liveInfo.value;
console.log(liveInfo.value);
} catch (err: any) {
console.error(err);
ElNotification({
title: "获取失败",
message: err.response.data.error || err.message,
type: "error"
});
}
};
watch(
() => newMovieInfo.value.live,
() => {
!newMovieInfo.value.live ? (newMovieInfo.value.rtmpSource = false) : void 0;
}
);
//
const { execute: reqPushMovieApi } = pushMovieApi();
const pushMovie = async (dir: string) => {
if (newMovieInfo.value.live) {
if (newMovieInfo.value.name === "")
return ElNotification({
title: "添加失败",
message: "请填写表单完整",
type: "error"
});
} else {
if (newMovieInfo.value.url === "" || newMovieInfo.value.name === "")
return ElNotification({
title: "添加失败",
message: "请填写表单完整",
type: "error"
});
}
try {
await reqPushMovieApi({
params: {
pos: dir
},
data: newMovieInfo.value,
headers: { Authorization: localStorage.token }
});
ElNotification({
title: "添加成功",
type: "success"
});
newMovieInfo.value.name = newMovieInfo.value.url = "";
getMovieList(false);
} catch (err: any) {
console.log(err);
ElNotification({
title: "添加失败",
message: err.response.data.error || err.message,
type: "error"
});
}
};
//
const { state: movieStatus, execute: reqMovieStatusApi } = movieStatusApi();
const getCurrentMovieStatus = async () => {
try {
await reqMovieStatusApi({
headers: { Authorization: localStorage.token }
});
if (movieStatus.value) {
setCurrentMovieStatus(movieStatus.value.current.status);
}
} catch (err: any) {
console.error(err.message);
ElNotification({
title: "获取失败",
type: "error",
message: err.response.data.error || err.message
});
}
};
//
const setCurrentMovieStatus = (movieStatus: MovieStatus) => {
room.currentMovieStatus.playing === movieStatus.playing
? void 0
: (room.currentMovieStatus.playing = movieStatus.playing);
room.currentMovieStatus.rate === movieStatus.rate
? void 0
: (room.currentMovieStatus.rate = movieStatus.rate);
if (
room.currentMovieStatus.seek - movieStatus.seek > 1 ||
room.currentMovieStatus.seek - movieStatus.seek > -2
)
room.currentMovieStatus.seek = movieStatus.seek;
};
//
let cMovieInfo = ref<EditMovieInfo>({
id: 0,
url: "",
name: "",
type: "",
headers: null
});
//
const editDialog = ref(false);
const openEditDialog = (item: MovieInfo) => {
cMovieInfo.value.id = item.id;
cMovieInfo.value.url = item.url;
cMovieInfo.value.name = item.name;
cMovieInfo.value.type = item.type;
cMovieInfo.value.headers = item.headers;
editDialog.value = true;
};
//
const { isLoading: editMovieInfoLoading, execute: reqEditMovieInfoApi } = editMovieInfoApi();
const editMovieInfo = async () => {
try {
await reqEditMovieInfoApi({
data: cMovieInfo.value,
headers: { Authorization: localStorage.token }
});
ElNotification({
title: "更新成功",
type: "success"
});
editDialog.value = false;
} catch (err: any) {
console.error(err.message);
ElNotification({
title: "更新失败",
type: "error",
message: err.response.data.error || err.message
});
}
};
//
const { execute: reqDelMovieApi } = delMovieApi();
const deleteMovie = async (ids: Array<number>) => {
try {
await reqDelMovieApi({
data: {
ids: ids
},
headers: { Authorization: localStorage.token }
});
for (const id of ids) {
movieList.value.splice(
movieList.value.findIndex((movie: MovieInfo) => movie["id"] === id),
1
);
}
ElNotification({
title: "删除成功",
type: "success"
});
selectMovies.value = [];
} catch (err: any) {
console.error(err);
ElNotification({
title: "删除失败",
message: err.response.data.error || err.message,
type: "error"
});
}
};
//
const selectMovies = ref<number[]>([]);
const { execute: reqSwapMovieApi } = swapMovieApi();
const swapMovie = async () => {
try {
await reqSwapMovieApi({
data: {
id1: selectMovies.value[0],
id2: selectMovies.value[1]
},
headers: { Authorization: localStorage.token }
});
ElNotification({
title: "交换成功",
type: "success"
});
selectMovies.value = [];
getMovieList(false);
} catch (err: any) {
console.error(err);
ElNotification({
title: "交换失败",
message: err.response.data.error || err.message,
type: "error"
});
}
};
//
const playerLoaded = ref(false);
const playerOptions = ref({
url: "",
isLive: false
});
const { execute: reqChangeCurrentMovieApi } = changeCurrentMovieApi();
const changeCurrentMovie = async (id: number) => {
// playerLoaded.value = false;
try {
await reqChangeCurrentMovieApi({
data: {
id: id
},
headers: { Authorization: localStorage.token }
});
ElNotification({
title: "设置成功",
type: "success"
});
// playerLoaded.value = true;
} catch (err: any) {
console.error(err);
ElNotification({
title: "设置失败",
message: err.response.data.error || err.message,
type: "error"
});
}
};
let noPlayArea: HTMLElement | null;
let playArea: HTMLElement | null;
//
let chatArea: HTMLElement | null;
let msgList = ref<string[]>([]);
//
const updateMsgList = (msg: string) => {
msgList.value.push(msg);
};
// ws
watch(
() => data.value,
() => {
if (data.value === "")
return localStorage.getItem("dev") === "114514" && console.log("返回了空", data.value);
const jsonData: WsMessage = JSON.parse(data.value);
localStorage.getItem("dev") === "114514" && console.log(`-----Ws Message Start-----`);
localStorage.getItem("dev") === "114514" && console.log(jsonData);
localStorage.getItem("dev") === "114514" && console.log(`-----Ws Message End-----`);
switch (jsonData.type) {
//
case WsMessageType.MESSAGE: {
msgList.value.push(`${jsonData.sender}${jsonData.message}`);
// jsonData.message.split("")[0] !== "PLAYER" &&
room.danmuku = {
text: jsonData.message, //
//time: Date.now(), //
color: "#fff", //
border: false //
//mode: 0, // : 0, 1
};
//
if (chatArea) chatArea.scrollTop = chatArea.scrollHeight;
if (msgList.value.length > 40)
return (msgList.value = [
"<p><b>SYSTEM</b>已达到最大聊天记录长度,系统已自动清空...</p>"
]);
break;
}
//
case WsMessageType.PLAY: {
setCurrentMovieStatus({
playing: true,
seek: jsonData.seek,
rate: jsonData.rate
});
break;
}
//
case WsMessageType.PAUSE: {
setCurrentMovieStatus({
playing: false,
seek: jsonData.seek,
rate: jsonData.rate
});
break;
}
//
case WsMessageType.SEEK: {
// room.currentMovie = jsonData.
if (
room.currentMovieStatus.seek - jsonData.seek > 1 ||
room.currentMovieStatus.seek - jsonData.seek < -2
)
room.currentMovieStatus.seek = jsonData.seek;
break;
}
//
case WsMessageType.CURRENT_MOVIE: {
room.currentMovie = jsonData.current.movie;
break;
}
//
case WsMessageType.PLAY_LIST_UPDATE: {
getMovieList(false);
// jsonData.movies
// ? (movieList.value = room.movieList = jsonData.movies)
// : movieList.value.splice(0, 1);
break;
}
//
case WsMessageType.PEOPLE_NUM: {
room.peopleNum < jsonData.peopleNum
? msgList.value.push(
`<p><b>SYSTEM</b>欢迎新成员加入,当前共有 ${jsonData.peopleNum} 人在观看</p>`
)
: room.peopleNum > jsonData.peopleNum
? msgList.value.push(
`<p><b>SYSTEM</b>有人离开了房间,当前还剩 ${jsonData.peopleNum} 人在观看</p>`
)
: "";
room.peopleNum = jsonData.peopleNum;
break;
}
}
}
);
//
let sendText_ = ref("");
const sendText = () => {
if (sendText_.value === "")
return ElMessage({
message: "发送的消息不能为空",
type: "warning"
});
const msg = JSON.stringify({
Type: 2,
Message: sendText_.value,
Time: Date.now()
});
send(msg);
sendText_.value = "";
chatArea!.scrollTop = chatArea!.scrollHeight;
localStorage.getItem("dev") === "114514" && console.log("sended:" + msg);
};
let player: ArtPlayer;
function getInstance(art: ArtPlayer) {
player = art;
}
//
const resetChatAreaHeight = () => {
chatArea = document.querySelector(".chatArea");
noPlayArea = document.querySelector(".noPlayArea");
playArea = document.querySelector(".playArea");
const h = playArea ? playArea : noPlayArea;
chatArea && h && (chatArea.style.height = h.scrollHeight - 63 + "px");
};
onMounted(() => {
setTimeout(() => {
resetChatAreaHeight();
}, 233);
watch(WindowWidth, () => {
resetChatAreaHeight();
});
getMovieList(true);
//
watch(
() => room.currentMovie,
() => {
const jsonData = room.currentMovie;
playerLoaded.value = false;
if (jsonData.pullKey !== "") {
jsonData.url = `${window.location.origin}/api/movie/live/${jsonData.pullKey}.flv`;
playerOptions.value = {
url: jsonData.url,
isLive: jsonData.live
};
setInterval(() => (playerLoaded.value = true), 20);
} else if (jsonData.url === "") {
// player.switchUrl("https://live.lazy.ink/hd.mp4");
playerLoaded.value = false;
} else {
localStorage.getItem("dev") === "114514" && console.log("变了!", jsonData.url);
playerOptions.value = {
url: jsonData.url,
isLive: jsonData.live
};
setInterval(() => (playerLoaded.value = true), 20);
}
}
);
});
onBeforeUnmount(() => {
if (player) player.destroy();
close();
});
</script>
<template>
<el-row :gutter="20">
<el-col :md="18" class="mb-6 max-sm:my-2">
<div class="card max-sm:rounded-none">
<div
class="card-title flex flex-wrap justify-between max-sm:text-sm"
v-if="room.currentMovie.url !== ''"
>
{{ room.currentMovie.name }}
<small>👁🗨 {{ room.peopleNum }} </small>
</div>
<div class="card-title flex flex-wrap justify-between max-sm:text-sm" v-else>
当前没有影片播放快去添加几部吧~<small class="font-normal"
>👁🗨 {{ room.peopleNum }}
</small>
</div>
<div class="card-body playArea max-sm:p-0" v-if="playerLoaded">
<div class="art-player">
<!--
https://www.llxz.cc/style/images/zhuye.mp4
-->
<Player
@set-player-status="send"
@ws-send="updateMsgList"
@get-instance="getInstance"
:options="playerOptions"
></Player>
</div>
</div>
<div class="card-body noPlayArea max-sm:pb-3 max-sm:px-3" v-else>
<!-- <img class="mx-auto" src="../assets/something-lost.png" /> -->
<el-carousel height="37vmax" indicator-position="none" arrow="never" interval="5000">
<el-carousel-item v-for="item in 4" :key="item">
<img class="mx-auto" :src="'https://api.imlazy.ink/img?t=' + item" />
</el-carousel-item>
</el-carousel>
</div>
<div class="card-footer p-4 max-sm:hidden"></div>
</div>
</el-col>
<el-col :md="6" class="mb-6 max-sm:mb-2">
<div class="card h-full max-sm:rounded-none">
<div class="card-title">在线聊天</div>
<div class="card-body mb-2">
<div class="chatArea">
<div class="message" v-for="item in msgList" :key="item">
<div v-html="item"></div>
</div>
</div>
</div>
<div class="card-footer" style="justify-content: center; padding: 0.5rem">
<input
type="text"
@keyup.enter="sendText()"
v-model="sendText_"
placeholder="按 Enter 键即可发送..."
class="l-input w-full bg-transparent"
/>
<button class="btn w-24 m-2.5 ml-0" @click="sendText()">发送</button>
</div>
</div>
</el-col>
</el-row>
<el-row :gutter="20">
<!-- 房间信息 -->
<el-col :lg="6" :md="8" :sm="9" :xs="24" class="mb-6 max-sm:mb-2">
<div class="card max-sm:rounded-none">
<div class="card-title">房间信息</div>
<div class="card-body">
<table class="table-auto i-table">
<tbody>
<tr>
<td width="100">连接状态</td>
<td>{{ status }}</td>
</tr>
<tr>
<td>房间ID</td>
<td>{{ roomID }}</td>
</tr>
<tr>
<td>房间密码</td>
<td>
<input
:type="isShowPassword ? 'text' : 'password'"
v-model="password"
class="w-full m-0 pl-1 inline-block bg-neutral-200 border border-neutral-200 rounded-md focus:outline-none hover:bg-neutral-100 transition-all text-sm dark:bg-neutral-700 dark:border-neutral-800"
/>
<button
class="inline-block absolute -translate-x-5 opacity-50 pr-0.5"
@click="isShowPassword = !isShowPassword"
>
{{ isShowPassword ? "●" : "◯" }}
</button>
</td>
</tr>
<tr>
<td>在线人数</td>
<td>{{ room.peopleNum }}</td>
</tr>
</tbody>
</table>
</div>
<div class="card-footer flex-wrap justify-between">
<el-popconfirm
width="220"
confirm-button-text="是"
cancel-button-text="否"
title="你确定要删除这个房间吗?!"
@confirm="deleteRoom"
>
<template #reference>
<button class="btn btn-error">删除房间</button>
</template>
</el-popconfirm>
<el-popconfirm
width="220"
confirm-button-text="是"
cancel-button-text="否"
title="更新后,所有人将会被踢下线!"
@confirm="changePassword"
>
<template #reference>
<button class="btn btn-success">更新房间密码</button>
</template>
</el-popconfirm>
</div>
</div>
</el-col>
<!-- 影片列表 -->
<el-col :lg="12" :md="16" :sm="15" :xs="24" class="mb-6 max-sm:mb-2">
<div class="card max-sm:rounded-none">
<div class="card-title">影片列表{{ movieList.length }}</div>
<div class="card-body">
<el-skeleton v-if="movieListLoading" :rows="1" animated />
<div
v-else
v-for="item in movieList"
:key="item.name"
class="flex justify-around mb-2 rounded-lg bg-zinc-50 hover:bg-white transition-all dark:bg-zinc-800 hover:dark:bg-neutral-800"
>
<div class="m-auto pl-2">
<input v-model="selectMovies" type="checkbox" :value="item['id']" />
</div>
<div class="overflow-hidden text-ellipsis m-auto p-2 w-7/12">
<b class="block text-base font-semibold" :title="`ID: ${item.id}`">
<el-tag class="mr-1" size="small" v-if="item.live"> 直播流 </el-tag>
{{ item["name"] }}
<button
v-if="item.live"
class="ml-1 font-normal text-sm border bg-rose-50 dark:bg-transparent border-rose-500 rounded-lg px-2 text-rose-500 hover:brightness-75 transition-all"
@click="getLiveInfo(item['id'])"
>
查看推流信息
</button>
</b>
<small class="truncate">{{ item["url"] || item["pullKey"] }}</small>
</div>
<div class="m-auto p-2">
<button class="btn btn-dense m-0 mr-1" @click="changeCurrentMovie(item['id'])">
播放
<PlayIcon class="inline-block" width="18px" />
</button>
<button class="btn btn-dense btn-warning m-0 mr-1" @click="openEditDialog(item)">
编辑
<EditIcon class="inline-block" width="16px" height="16px" />
</button>
<el-popconfirm
width="220"
confirm-button-text="是"
cancel-button-text="否"
title="你确定要删除这条影片吗?"
@confirm="deleteMovie([item['id']])"
>
<template #reference>
<button class="btn btn-dense btn-error m-0 mr-1">
删除
<TrashIcon class="inline-block" width="16px" height="16px" />
</button>
</template>
</el-popconfirm>
</div>
</div>
</div>
<div class="card-footer justify-between">
<div>
<button class="btn mr-2" v-if="selectMovies.length === 2" @click="swapMovie">
交换位置
</button>
<el-popconfirm
v-if="selectMovies.length >= 2"
width="220"
confirm-button-text="是"
cancel-button-text="否"
title="你确定要删除这些影片吗?"
@confirm="deleteMovie(selectMovies)"
>
<template #reference>
<button class="btn btn-error">批量删除</button>
</template>
</el-popconfirm>
</div>
<div></div>
<div>
<el-popconfirm
width="220"
confirm-button-text="是"
cancel-button-text="否"
title="你确定要清空影片列表吗?!"
@confirm="clearMovieList"
>
<template #reference>
<button class="btn btn-error mr-2">清空列表</button>
</template>
</el-popconfirm>
<button class="btn btn-success" @click="getMovieList(true)">更新列表</button>
</div>
</div>
</div>
</el-col>
<!-- 添加影片 -->
<el-col :lg="6" :md="14" :xs="24" class="mb-6 max-sm:mb-2">
<div class="card max-sm:rounded-none">
<div class="card-title">添加影片</div>
<div class="card-body flex justify-around flex-wrap">
<input
type="text"
placeholder="影片Url"
class="l-input-violet mb-1.5 w-full"
v-if="!(newMovieInfo.live && newMovieInfo.rtmpSource)"
v-model="newMovieInfo.url"
/>
<input
type="text"
placeholder="影片名称"
class="l-input-slate mt-1.5 w-full"
v-model="newMovieInfo.name"
/>
<div class="mt-4 mb-0 flex flex-wrap justify-around w-full">
<div>
<input type="checkbox" v-model="newMovieInfo.live" />
<label>&nbsp;这是一条直播流</label>
</div>
<div>
<input
type="checkbox"
v-model="newMovieInfo.rtmpSource"
@click="newMovieInfo.live ? true : (newMovieInfo.live = true)"
/>
<label>&nbsp;我想创建直播</label>
</div>
<!-- <div>
<input type="checkbox" v-model="newMovieInfo.proxy" />
<label>&nbsp;isProxy</label>
</div> -->
</div>
</div>
<div class="card-footer flex-wrap pt-3" style="justify-content: space-around">
<button class="btn" @click="pushMovie('front')">添加到列表最<b></b></button>
<button class="btn" @click="pushMovie('back')">添加到列表最<b></b></button>
</div>
</div>
</el-col>
</el-row>
<!-- 编辑影片对话框 -->
<el-dialog
v-model="editDialog"
title="编辑影片"
width="443px"
class="rounded-lg dark:bg-zinc-800"
>
<el-form label-position="top">
<el-form-item label="Url">
<input type="text" class="l-input m-0 p-0 pl-2 w-full" v-model="cMovieInfo.url" />
</el-form-item>
<el-form-item label="Name">
<input type="text" class="l-input m-0 p-0 pl-2 w-full" v-model="cMovieInfo.name" />
</el-form-item>
</el-form>
<template #footer>
<button class="btn mr-2" @click="editDialog = false">取消</button>
<button class="btn btn-success contrast-50" disabled v-if="editMovieInfoLoading">
请求中...
</button>
<button class="btn btn-success" @click="editMovieInfo()" v-else>确定修改</button>
</template>
</el-dialog>
<!-- 直播推流信息 -->
<el-dialog
v-model="liveInfoDialog"
title="直播推流信息"
width="443px"
class="rounded-lg dark:bg-zinc-800"
>
<el-form label-position="top">
<el-form-item label="推流地址:">
<input
type="text"
class="l-input m-0 p-0 pl-2 w-full"
:value="`rtmp://${liveInfoForm.host}/${liveInfoForm.app}/`"
/>
</el-form-item>
<el-form-item label="推流密钥:">
<input type="text" class="l-input m-0 p-0 pl-2 w-full" :value="liveInfoForm.token" />
</el-form-item>
</el-form>
<template #footer>
<button class="btn btn-success" @click="liveInfoDialog = false">我已知晓</button>
</template>
</el-dialog>
</template>
<style lang="less" scoped>
.art-player {
// margin-bottom: 10px;
}
.chatArea {
overflow-y: scroll;
// height: 33vmax;
}
.i-table {
td {
padding: 2px 0 2px;
}
}
.a::after {
content: "";
width: 100%;
background-image: url("https://api.imlazy.ink/img/?");
}
</style>

126
src/views/CreateRoom.vue Normal file
View File

@ -0,0 +1,126 @@
<script setup lang="ts">
import { ref } from "vue";
import { ElNotification } from "element-plus";
import { roomStore } from "@/stores/room";
import router from "@/router/index";
import { createRoomApi } from "@/services/apis/room";
const room = roomStore();
const { state: createRoomToken, execute: reqCreateRoomApi } = createRoomApi();
const formData = ref({
roomId: "",
password: "",
username: localStorage.getItem("uname") || "",
userPassword: "",
hidden: false
});
const savePwd = ref(false);
const operateRoom = async () => {
if (formData.value?.username === "" || formData.value?.roomId === "") {
ElNotification({
title: "错误",
message: "请填写表单完整",
type: "error"
});
return;
}
try {
await reqCreateRoomApi({
data: formData.value
});
if (!createRoomToken.value)
return ElNotification({
title: "错误",
message: "服务器并未返回token",
type: "error"
});
localStorage.setItem("token", createRoomToken.value?.token);
ElNotification({
title: "创建成功",
type: "success"
});
room.login = true;
localStorage.setItem("uname", formData.value.username);
savePwd.value && localStorage.setItem("uPasswd", formData.value.userPassword);
localStorage.setItem("roomId", formData.value.roomId);
savePwd.value && localStorage.setItem("password", formData.value.password);
localStorage.setItem("login", "true");
router.replace("/cinema");
} catch (err: any) {
console.error(err);
ElNotification({
title: "错误",
message: err.response.data.error || err.message,
type: "error"
});
}
};
</script>
<template>
<div class="room">
<form @submit.prevent="" class="sm:w-96 w-full">
<input
class="l-input"
type="text"
v-model="formData.username"
placeholder="用户名"
required
/>
<br />
<input
class="l-input"
type="text"
v-model="formData.userPassword"
placeholder="密码"
required
/>
<br />
<input class="l-input" type="text" v-model="formData.roomId" placeholder="房间名" required />
<br />
<input class="l-input" type="password" v-model="formData.password" placeholder="房间密码" />
<br />
<div>
<input class="w-auto" type="checkbox" v-model="formData.hidden" />
<label class="mr-6" title="不显示在房间列表">&nbsp;是否隐藏此房间</label>
<input class="w-auto" type="checkbox" v-model="savePwd" />
<label title="明文保存到本机哦~">&nbsp;记住密码</label>
</div>
<button class="btn m-[10px]" @click="operateRoom()">创建房间</button>
</form>
</div>
</template>
<style lang="less" scoped>
.room {
text-align: center;
margin-top: 5vmax;
form {
// width: 443px;
margin: auto;
input {
width: 70%;
&:hover {
padding: 10px 15px;
width: 74%;
}
}
.btn {
padding: 10px 15px;
width: 70%;
&:hover {
padding: 12px 15px;
}
}
}
}
</style>

20
src/views/HomeView.vue Normal file
View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import { roomStore } from "@/stores/room";
import RoomList from "@/components/RoomList.vue";
const room = roomStore();
const devMode = localStorage.getItem("dev") === "114514" ? true : false;
</script>
<template>
<div class="text-center">
<h1 class="text-3xl">首页</h1>
<br />
<RoomList />
<button class="btn" @click="room.increment" v-if="devMode">
{{ room.count }}
</button>
<br />
<p class="text-zinc-400">*开发中页面可能是最终品质</p>
<p>&copy; Copyright 2023 Lazy all right reserved</p>
</div>
</template>

172
src/views/JoinRoom.vue Normal file
View File

@ -0,0 +1,172 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { ElNotification } from "element-plus";
import { roomStore } from "@/stores/room";
import router from "@/router/index";
import { useRoute } from "vue-router";
import { joinRoomApi } from "@/services/apis/room";
const route = useRoute();
const room = roomStore();
//
const isModal = computed(() => {
return route.name === "joinRoom" ? false : true;
});
const props = defineProps<{
item?: {
roomId: string;
password: string;
username: string;
userPassword: string;
};
}>();
const formData = ref({
roomId: localStorage.getItem("roomId") || "",
password: localStorage.getItem("password") || "",
username: localStorage.getItem("uname") || "",
userPassword: localStorage.getItem("uPasswd") || ""
});
if (props.item) formData.value = props.item;
const savePwd = ref(localStorage.getItem("uPasswd") ? true : false);
const { state: joinRoomToken, execute: reqJoinRoomApi } = joinRoomApi();
const JoinRoom = async () => {
if (formData.value?.username === "" || formData.value?.roomId === "") {
ElNotification({
title: "错误",
message: "请填写表单完整",
type: "error"
});
return;
}
try {
await reqJoinRoomApi({
data: formData.value,
params: {
autoNew: true
}
});
if (!joinRoomToken.value)
return ElNotification({
title: "错误",
message: "服务器并未返回token",
type: "error"
});
localStorage.setItem("token", joinRoomToken.value?.token);
ElNotification({
title: "加入成功",
type: "success"
});
room.login = true;
localStorage.setItem("uname", formData.value.username);
savePwd.value && localStorage.setItem("uPasswd", formData.value.userPassword);
localStorage.setItem("roomId", formData.value.roomId);
savePwd.value && localStorage.setItem("password", formData.value.password);
localStorage.setItem("login", "true");
router.replace("/cinema");
} catch (err: any) {
console.error(err);
ElNotification({
title: "错误",
message: err.response.data.error || err.message,
type: "error"
});
}
};
</script>
<template>
<div :class="isModal ? 'room-dialog' : 'room'">
<form @submit.prevent="" :class="!isModal && 'sm:w-96 ' + 'w-full'">
<input
class="l-input"
type="text"
v-model="formData.username"
placeholder="用户名"
required
/>
<br />
<input
class="l-input"
type="password"
v-model="formData.userPassword"
placeholder="密码"
required
/>
<br />
<input class="l-input" type="text" v-model="formData.roomId" placeholder="房间名" required />
<br />
<input class="l-input" type="password" v-model="formData.password" placeholder="房间密码" />
<br />
<div>
<input class="w-auto" type="checkbox" v-model="savePwd" />
<label title="明文保存到本机哦~">&nbsp;记住密码</label>
</div>
<button class="btn m-[10px]" @click="JoinRoom()">
{{ "加入" }}
</button>
</form>
</div>
</template>
<style lang="less" scoped>
.room {
text-align: center;
margin-top: 5vmax;
form {
margin: auto;
input {
width: 70%;
&:hover {
padding: 10px 15px;
width: 74%;
}
}
.btn {
padding: 10px 15px;
width: 70%;
&:hover {
padding: 12px 15px;
}
}
}
}
.room-dialog {
text-align: center;
form {
margin: auto;
input {
width: 80%;
&:hover {
padding: 10px 15px;
width: 84%;
}
}
.btn {
padding: 10px 15px;
width: 80%;
&:hover {
padding: 12px 15px;
}
}
}
}
</style>

14
tailwind.config.js Normal file
View File

@ -0,0 +1,14 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
important: true,
darkMode: 'class',
}

12
tsconfig.app.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue","src/**/*.ts","src/**/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
],
"compilerOptions": {
"target": "ES2016",
"noImplicitAny": true,
"moduleResolution": "bundler"
}
}

15
tsconfig.node.json Normal file
View File

@ -0,0 +1,15 @@
{
"extends": "@tsconfig/node18/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"module": "ESNext",
"types": ["node"]
}
}

41
vite.config.ts Normal file
View File

@ -0,0 +1,41 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
const env = loadEnv('', process.cwd())
// https://vitejs.dev/config/
export default defineConfig({
server: {
host: true,
proxy: {
"/api": {
target: "http://127.0.0.1:8088",
ws: true,
},
}
},
plugins: [
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
vue()
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
build: {
cssCodeSplit: true,
minify: 'terser'
},
base: env.VITE_BASEURL
})