merge v4.1.1 and fixed conflicts

This commit is contained in:
RockYang
2024-11-13 18:40:04 +08:00
110 changed files with 4820 additions and 1977 deletions

View File

@@ -0,0 +1,77 @@
<template>
<button v-if="showButton" @click="scrollToTop" class="scroll-to-top" :style="{bottom: bottom + 'px', right: right + 'px', backgroundColor: bgColor}">
<el-icon><ArrowUpBold /></el-icon>
</button>
</template>
<script>
import {ArrowUpBold} from "@element-plus/icons-vue";
export default {
name: 'BackTop',
components: {ArrowUpBold},
props: {
bottom: {
type: Number,
default: 30
},
right: {
type: Number,
default: 30
},
bgColor: {
type: String,
default: '#007bff'
}
},
data() {
return {
showButton: false
};
},
mounted() {
this.checkScroll();
window.addEventListener('resize', this.checkScroll);
this.$el.parentElement.addEventListener('scroll', this.checkScroll);
},
beforeUnmount() {
window.removeEventListener('resize', this.checkScroll);
this.$el.parentElement.removeEventListener('scroll', this.checkScroll);
},
methods: {
scrollToTop() {
const container = this.$el.parentElement;
container.scrollTo({
top: 0,
behavior: 'smooth'
});
},
checkScroll() {
const container = this.$el.parentElement;
this.showButton = container.scrollTop > 50;
}
}
}
</script>
<style scoped lang="stylus">
.scroll-to-top {
position: fixed;
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
outline: none;
transition: opacity 0.3s;
width 40px
height 40px
display flex
justify-content center
align-items center
font-size 20px
&:hover {
opacity: 0.6;
}
}
</style>

View File

@@ -1,244 +0,0 @@
<template>
<div class="chat-line chat-line-mj" v-loading="loading">
<div class="chat-line-inner">
<div class="chat-icon">
<img :src="icon" alt="User"/>
</div>
<div class="chat-item">
<div class="content">
<div class="text" v-html="data.html"></div>
<div class="images" v-if="data.image?.url !== ''">
<el-image :src="data.image?.url"
:zoom-rate="1.2"
:preview-src-list="[data.image?.url]"
fit="cover"
:initial-index="0" loading="lazy">
<template #placeholder>
<div class="image-slot"
:style="{height: height+'px', lineHeight:height+'px'}">
正在加载图片<span class="dot">...</span></div>
</template>
<template #error>
<div class="image-slot">
<el-icon>
<Picture/>
</el-icon>
</div>
</template>
</el-image>
</div>
</div>
<div class="opt" v-if="data.showOpt &&data.image?.hash !== ''">
<div class="opt-line">
<ul>
<li><a @click="upscale(1)">U1</a></li>
<li><a @click="upscale(2)">U2</a></li>
<li><a @click="upscale(3)">U3</a></li>
<li><a @click="upscale(4)">U4</a></li>
</ul>
</div>
<div class="opt-line">
<ul>
<li><a @click="variation(1)">V1</a></li>
<li><a @click="variation(2)">V2</a></li>
<li><a @click="variation(3)">V3</a></li>
<li><a @click="variation(4)">V4</a></li>
</ul>
</div>
</div>
<div class="bar" v-if="createdAt !== ''">
<span class="bar-item"><el-icon><Clock/></el-icon> {{ createdAt }}</span>
<span class="bar-item">tokens: {{ tokens }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {ref, watch} from "vue";
import {Clock, Picture} from "@element-plus/icons-vue";
import {ElMessage} from "element-plus";
import {httpPost} from "@/utils/http";
import {getSessionId} from "@/store/session";
const props = defineProps({
content: Object,
icon: String,
chatId: String,
roleId: Number,
createdAt: String
});
const data = ref(props.content)
const tokens = ref(0)
const cacheKey = "img_placeholder_height"
const item = localStorage.getItem(cacheKey);
const loading = ref(false)
const height = ref(0)
if (item) {
height.value = parseInt(item)
}
if (data.value["image"]?.width > 0) {
height.value = 350 * data.value["image"]?.height / data.value["image"]?.width
localStorage.setItem(cacheKey, height.value)
}
data.value["showOpt"] = data.value["content"]?.indexOf("- Image #") === -1;
// console.log(data.value)
watch(() => props.content, (newVal) => {
data.value = newVal;
});
const emits = defineEmits(['disable-input', 'disable-input']);
const upscale = (index) => {
send('/api/mj/upscale', index)
}
const variation = (index) => {
send('/api/mj/variation', index)
}
const send = (url, index) => {
loading.value = true
emits('disable-input')
httpPost(url, {
index: index,
src: "chat",
message_id: data.value?.["message_id"],
message_hash: data.value?.["image"]?.hash,
session_id: getSessionId(),
prompt: data.value?.["prompt"],
chat_id: props.chatId,
role_id: props.roleId,
icon: props.icon,
}).then(() => {
ElMessage.success("任务推送成功,请耐心等待任务执行...")
loading.value = false
}).catch(e => {
ElMessage.error("任务推送失败:" + e.message)
emits('disable-input')
})
}
</script>
<style lang="stylus">
.chat-line-mj {
background-color #ffffff;
justify-content: center;
width 100%
padding-bottom: 1.5rem;
padding-top: 1.5rem;
border-bottom: 1px solid #d9d9e3;
.chat-line-inner {
display flex;
width 100%;
max-width 900px;
padding-left 10px;
.chat-icon {
margin-right 20px;
img {
width: 36px;
height: 36px;
border-radius: 10px;
padding: 1px;
}
}
.chat-item {
position: relative;
padding: 0 5px 0 0;
overflow: hidden;
.content {
word-break break-word;
padding: 6px 10px;
color #374151;
font-size: var(--content-font-size);
border-radius: 5px;
overflow: auto;
.text {
p:first-child {
margin-top 0
}
}
.images {
max-width 350px;
.el-image {
border-radius 10px;
.image-slot {
color #c1c1c1
width 350px
text-align center
border-radius 10px;
border 1px solid #e1e1e1
}
}
}
}
.opt {
.opt-line {
margin 6px 0
ul {
display flex
flex-flow row
padding-left 10px
li {
margin-right 10px
a {
padding 6px 0
width 64px
text-align center
border-radius 5px
display block
cursor pointer
background-color #4E5058
color #ffffff
&:hover {
background-color #6D6F78
}
}
}
}
}
}
.bar {
padding 10px;
.bar-item {
background-color #f7f7f8;
color #888
padding 3px 5px;
margin-right 10px;
border-radius 5px;
.el-icon {
position relative
top 2px;
}
}
}
}
}
}
</style>

View File

@@ -70,7 +70,7 @@
</div>
<div class="bar" v-if="data.created_at > 0">
<span class="bar-item"><el-icon><Clock/></el-icon> {{ dateFormat(data.created_at) }}</span>
<span class="bar-item">tokens: {{ finalTokens }}</span>
<!-- <span class="bar-item">tokens: {{ finalTokens }}</span>-->
</div>
</div>
</div>
@@ -132,27 +132,38 @@ const content =ref(processPrompt(props.data.content))
const files = ref([])
onMounted(() => {
if (!finalTokens.value) {
httpPost("/api/chat/tokens", {text: props.data.content, model: props.data.model}).then(res => {
finalTokens.value = res.data;
}).catch(() => {
})
}
// if (!finalTokens.value) {
// httpPost("/api/chat/tokens", {text: props.data.content, model: props.data.model}).then(res => {
// finalTokens.value = res.data;
// }).catch(() => {
// })
// }
const linkRegex = /(https?:\/\/\S+)/g;
const links = props.data.content.match(linkRegex);
if (links) {
httpPost("/api/upload/list", {urls: links}).then(res => {
files.value = res.data
for (let link of links) {
if (isExternalImg(link, files.value)) {
files.value.push({url:link, ext: ".png"})
}
}
}).catch(() => {
})
for (let link of links) {
content.value = content.value.replace(link,"")
}
}
content.value = md.render(content.value.trim())
})
const isExternalImg = (link, files) => {
return isImage(link) && !files.find(file => file.url === link)
}
</script>
<style lang="stylus">
@@ -297,9 +308,10 @@ onMounted(() => {
display flex;
width 100%;
padding 0 25px;
flex-flow row-reverse
.chat-icon {
margin-right 20px;
margin-left 20px;
img {
width: 36px;
@@ -366,6 +378,7 @@ onMounted(() => {
.content-wrapper {
display flex
flex-flow row-reverse
.content {
word-break break-word;
padding: 1rem
@@ -373,7 +386,7 @@ onMounted(() => {
font-size: var(--content-font-size);
overflow: auto;
background-color #98e165
border-radius: 0 10px 10px 10px;
border-radius: 10px 0 10px 10px;
img {
max-width: 600px;

View File

@@ -67,14 +67,13 @@
<div class="chat-icon">
<img :src="data.icon" alt="ChatGPT">
</div>
<div class="chat-item">
<div class="content-wrapper">
<div class="content" v-html="data.content"></div>
</div>
<div class="bar" v-if="data.created_at">
<span class="bar-item"><el-icon><Clock/></el-icon> {{ dateFormat(data.created_at) }}</span>
<span class="bar-item">tokens: {{ data.tokens }}</span>
<!-- <span class="bar-item">tokens: {{ data.tokens }}</span>-->
<span class="bar-item bg">
<el-tooltip
class="box-item"
@@ -340,18 +339,15 @@ const reGenerate = (prompt) => {
.chat-line-reply-chat {
justify-content: center;
width 100%
padding-bottom: 1.5rem;
padding-top: 1.5rem;
padding 1.5rem;
.chat-line-inner {
display flex;
padding 0 25px;
width 100%
flex-flow row-reverse
flex-flow row
.chat-icon {
margin-left 20px;
margin-right 20px;
img {
width: 36px;
@@ -365,11 +361,10 @@ const reGenerate = (prompt) => {
position: relative;
padding: 0;
overflow: hidden;
max-width 60%
max-width 70%
.content-wrapper {
display flex
flex-flow row-reverse
.content {
min-height 20px;
word-break break-word;
@@ -378,7 +373,7 @@ const reGenerate = (prompt) => {
font-size: var(--content-font-size);
overflow auto;
background-color #F5F5F5
border-radius: 10px 0 10px 10px;
border-radius: 0 10px 10px 10px;
img {
max-width: 600px;

View File

@@ -1,22 +1,47 @@
<template>
<div class="foot-container">
<div class="footer">
Powered by {{ author }} @
<el-link type="primary" :href="gitURL" target="_blank" style="--el-link-text-color:#ffffff">
{{ title }} -
{{ version }}
</el-link>
<div><span :style="{color:textColor}">{{copyRight}}</span></div>
<div v-if="!license.de_copy">
<a :href="gitURL" target="_blank" :style="{color:textColor}">
{{ title }} -
{{ version }}
</a>
</div>
</div>
</div>
</template>
<script setup>
import {ref} from "vue";
import {httpGet} from "@/utils/http";
import {showMessageError} from "@/utils/dialog";
const title = ref(process.env.VUE_APP_TITLE)
const title = ref("")
const version = ref(process.env.VUE_APP_VERSION)
const gitURL = ref(process.env.VUE_APP_GIT_URL)
const author = ref('极客学长')
const copyRight = ref('')
const license = ref({})
const props = defineProps({
textColor: {
type: String,
default: '#ffffff'
},
});
// 获取系统配置
httpGet("/api/config/get?key=system").then(res => {
title.value = res.data.title??process.env.VUE_APP_TITLE
copyRight.value = res.data.copyright.length>1?res.data.copyright:'极客学长 © 2023 - '+new Date().getFullYear()+' All rights reserved.'
}).catch(e => {
showMessageError("获取系统配置失败:" + e.message)
})
httpGet("/api/config/license").then(res => {
license.value = res.data
}).catch(e => {
showMessageError("获取 License 失败:" + e.message)
})
</script>
<style scoped lang="stylus">
@@ -35,8 +60,10 @@ const author = ref('极客学长')
padding 20px;
width 100%
.el-link {
color #409eff
a {
&:hover {
text-decoration underline
}
}
}
}

View File

@@ -241,8 +241,8 @@ watch(() => props.show, (newValue) => {
const login = ref(true)
const data = ref({
username: "",
password: "",
username: process.env.VUE_APP_USER,
password: process.env.VUE_APP_PASS,
repass: "",
code: "",
invite_code: ""
@@ -251,7 +251,7 @@ const enableMobile = ref(false)
const enableEmail = ref(false)
const enableUser = ref(false)
const enableRegister = ref(false)
const activeName = ref("mobile")
const activeName = ref("")
const wxImg = ref("/images/wx.png")
// eslint-disable-next-line no-undef
const emits = defineEmits(['hide', 'success']);
@@ -261,12 +261,15 @@ httpGet("/api/config/get?key=system").then(res => {
const registerWays = res.data['register_ways']
if (arrayContains(registerWays, "mobile")) {
enableMobile.value = true
activeName.value = activeName.value === "" ? "mobile" : activeName.value
}
if (arrayContains(registerWays, "email")) {
enableEmail.value = true
activeName.value = activeName.value === "" ? "email" : activeName.value
}
if (arrayContains(registerWays, "username")) {
enableUser.value = true
activeName.value = activeName.value === "" ? "username" : activeName.value
}
// 是否启用注册
enableRegister.value = res.data['enabled_register']

View File

@@ -0,0 +1,282 @@
<template>
<div class="player">
<div class="container">
<div class="cover">
<el-image :src="cover" fit="cover" />
</div>
<div class="info">
<div class="title">{{title}}</div>
<div class="style">
<span class="tags">{{ tags }}</span>
<span class="text-lightGray"> | </span>
<span class="time">{{ formatTime(currentTime) }}<span class="split">/</span>{{ formatTime(duration) }}</span>
</div>
</div>
<div class="controls-container">
<div class="controls">
<button @click="prevSong" class="control-btn">
<i class="iconfont icon-prev"></i>
</button>
<button @click="togglePlay" class="control-btn">
<i class="iconfont icon-play" v-if="!isPlaying"></i>
<i class="iconfont icon-pause" v-else></i>
</button>
<button @click="nextSong" class="control-btn">
<i class="iconfont icon-next"></i>
</button>
</div>
</div>
<div class="progress-bar" @click="setProgress" ref="progressBarRef">
<div class="progress" :style="{ width: `${progressPercent}%` }"></div>
</div>
<audio ref="audio" @timeupdate="updateProgress" @ended="nextSong"></audio>
<el-button v-if="showClose" class="close" type="info" :icon="Close" circle size="small" @click="emits('close')" />
</div>
</div>
</template>
<script setup>
import {ref, onMounted, watch} from 'vue';
import {showMessageError} from "@/utils/dialog";
import {Close} from "@element-plus/icons-vue";
import {formatTime} from "@/utils/libs";
import {httpGet} from "@/utils/http"
const audio = ref(null);
const isPlaying = ref(false);
const songIndex = ref(0);
const currentTime = ref(0);
const duration = ref(100);
const progressPercent = ref(0);
const progressBarRef = ref(null)
const title = ref("")
const tags = ref("")
const cover = ref("")
// eslint-disable-next-line no-undef
const props = defineProps({
songs: {
type: Array,
required: true,
default: () => []
},
showClose: {
type: Boolean,
default: false
}
});
// eslint-disable-next-line no-undef
const emits = defineEmits(['close','play']);
watch(() => props.songs, (newVal) => {
loadSong(newVal[songIndex.value]);
});
const loadSong = (song) => {
if (!song) {
showMessageError("歌曲加载失败")
return
}
title.value = song.title
tags.value = song.tags
cover.value = song.cover_url
audio.value.src = song.audio_url;
audio.value.load();
isPlaying.value = false
audio.value.onloadedmetadata = () => {
duration.value = audio.value.duration;
};
};
const togglePlay = () => {
if (isPlaying.value) {
audio.value.pause();
isPlaying.value = false
} else {
play()
}
};
const play = () => {
if (isPlaying.value) {
return
}
audio.value.play();
isPlaying.value = true
if (audio.value.currentTime === 0) {
emits("play")
// 增加播放数量
httpGet("/api/suno/play",{song_id:props.songs[songIndex.value].song_id}).then().catch()
}
}
const prevSong = () => {
songIndex.value = (songIndex.value - 1 + props.songs.length) % props.songs.length;
loadSong(props.songs[songIndex.value]);
audio.value.play();
isPlaying.value = true;
};
const nextSong = () => {
songIndex.value = (songIndex.value + 1) % props.songs.length;
loadSong(props.songs[songIndex.value]);
audio.value.play();
isPlaying.value = true;
};
const updateProgress = () => {
try {
currentTime.value = audio.value.currentTime;
progressPercent.value = (currentTime.value / duration.value) * 100;
} catch (e) {
console.error(e.message)
}
};
const setProgress = (event) => {
const totalWidth = progressBarRef.value.offsetWidth;
const clickX = event.offsetX;
const audioDuration = audio.value.duration;
audio.value.currentTime = (clickX / totalWidth) * audioDuration;
};
// eslint-disable-next-line no-undef
defineExpose({
play
});
onMounted(() => {
loadSong(props.songs[songIndex.value]);
});
</script>
<style lang="stylus" scoped>
.player {
display flex
justify-content center
width 100%
.container {
display flex
background-color: #363030;
border-radius: 10px;
border 1px solid #544F4F;
padding: 5px;
width: 80%
text-align: center;
position relative
overflow hidden
.cover {
.el-image {
border-radius: 50%;
width 50px
}
}
.info {
padding 0 10px
min-width 300px
display flex
justify-content center
align-items flex-start
flex-flow column
line-height 1.5
.title {
font-weight 700
font-size 16px
color #ffffff
}
.style {
font-size 14px
display flex
color #e1e1e1
.tags {
font-weight 600
white-space: nowrap; /* 防止文本换行 */
overflow: hidden; /* 隐藏溢出的文本 */
text-overflow: ellipsis; /* 使用省略号表示溢出的文本 */
max-width 200px
}
.text-lightGray {
color: rgb(114 110 108);
padding 0 3px
}
.time {
font-family 'Input Sans'
font-weight 700
.split {
font-size 12px
position relative
top -2px
margin 0 1px 0 3px
}
}
}
}
.controls-container {
width 100%
display flex
flex-flow column
justify-content center
.controls {
display: flex;
justify-content: space-around;
margin-bottom 10px
.control-btn {
background: none;
border: none;
color: #fff;
cursor: pointer;
background-color #363030
border-radius 5px
padding 6px
.iconfont {
font-size 20px
}
&:hover {
background-color #5F5958
}
}
}
}
.progress-bar {
position absolute
width 100%
left 0
bottom 0
height: 8px;
background-color: #555;
cursor: pointer;
.progress {
height: 100%;
background-color: #f50;
border-radius: 5px;
width: 0;
}
}
.close {
position absolute
right 10px
top 15px
}
}
}
</style>

View File

@@ -58,8 +58,8 @@ import {httpGet} from "@/utils/http";
import {ElMessage} from "element-plus";
import {useRoute} from "vue-router";
const title = ref('Chat-Plus-Admin')
const logo = ref('/images/logo.png')
const title = ref('')
const logo = ref('')
// 加载系统配置
httpGet('/api/admin/config/get?key=system').then(res => {

View File

@@ -0,0 +1,106 @@
<template>
<div class="black-dialog">
<el-dialog
v-model="showDialog"
style="--el-dialog-bg-color:#414141;
--el-text-color-primary:#f1f1f1;
--el-border-color:#414141;
--el-color-primary:#21aa93;
--el-color-primary-dark-2:#41555d;
--el-color-white: #e1e1e1;
--el-color-primary-light-3:#549688;
--el-fill-color-blank:#616161;
--el-color-primary-light-7:#717171;
--el-color-primary-light-9:#717171;
--el-text-color-regular:#e1e1e1"
:title="title"
:width="width"
:before-close="cancel"
>
<div class="dialog-body">
<slot></slot>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel">{{cancelText}}</el-button>
<el-button type="primary" @click="$emit('confirm')">{{confirmText}}</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import {ref, watch} from "vue";
const props = defineProps({
show : Boolean,
title: {
type: String,
default: 'Tips',
},
width: {
type: Number,
default: 500,
},
confirmText: {
type: String,
default: '确定',
},
cancelText: {
type: String,
default: '取消',
},
});
const emits = defineEmits(['confirm','cancal']);
const showDialog = ref(props.show)
watch(() => props.show, (newValue) => {
showDialog.value = newValue
})
const cancel = () => {
showDialog.value = false
emits('cancal')
}
</script>
<style lang="stylus">
.black-dialog {
.dialog-body {
.form {
.form-item {
display flex
flex-flow column
font-family: "Neue Montreal";
padding 10px 0
.label {
margin-bottom 0.6rem
margin-inline-end 0.75rem
color #ffffff
font-size 1rem
font-weight 500
}
.input {
display flex
padding 10px
text-align left
font-size 1rem
background none
border-radius 0.375rem
border 1px solid #8f8f8f
outline: none;
transition: border-color 0.5s ease, box-shadow 0.5s ease;
&:focus {
border-color: #0F7A71;
box-shadow: 0 0 5px #0F7A71;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="black-input-wrapper">
<el-input v-model="model" :type="type" :rows="rows"
@input="onInput"
style="--el-input-bg-color:#252020;
--el-input-border-color:#414141;
--el-input-focus-border-color:#414141;
--el-text-color-regular: #f1f1f1;
--el-input-border-radius: 10px;
--el-border-color-hover:#616161"
resize="none"
:placeholder="placeholder" :maxlength="maxlength"/>
<div class="word-stat" v-if="rows > 1">
<span>{{value.length}}</span>/<span>{{maxlength}}</span>
</div>
</div>
</template>
<script setup>
import {ref, watch} from "vue";
const props = defineProps({
value : {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
type: {
type: String,
default: 'input',
},
rows: {
type: Number,
default: 5,
},
maxlength: {
type: Number,
default: 1024
}
});
watch(() => props.value, (newValue) => {
model.value = newValue
})
const model = ref(props.value)
const emits = defineEmits(['update:value']);
const onInput = (value) => {
emits('update:value',value)
}
</script>
<style lang="stylus">
.black-input-wrapper {
position relative
.el-textarea__inner {
padding: 20px;
font-size: 16px;
}
.word-stat {
position: absolute;
bottom 10px
right 10px
color rgb(209 203 199)
font-family: Neue Montreal, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
font-size .875rem
line-height 1.25rem
span {
margin 0 1px
}
}
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<el-select v-model="model" :placeholder="placeholder"
:value="value" @change="$emit('update:value', $event)"
style="--el-fill-color-blank:#252020;
--el-text-color-regular: #a1a1a1;
--el-select-disabled-color:#0E0808;
--el-color-primary-light-9:#0E0808;
--el-border-radius-base:20px;
--el-border-color:#0E0808;">
<el-option v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value">
</el-option>
</el-select>
</template>
<script>
export default {
name: 'BlackSelect',
props: {
value : {
type: String,
default: '',
},
placeholder: {
type: String,
default: '请选择',
},
options: {
type: Array,
default: []
}
},
data() {
return {
model: this.value
}
}
}
</script>

View File

@@ -0,0 +1,24 @@
<template>
<el-switch v-model="model" :size="size"
@change="$emit('update:value', $event)"
style="--el-switch-on-color:#555555;--el-color-white:#0E0808"/>
</template>
<script setup>
import {ref, watch} from "vue";
const props = defineProps({
value : Boolean,
size: {
type: String,
default: 'default',
}
});
const model = ref(props.value)
watch(() => props.value, (newValue) => {
model.value = newValue
})
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div class="container">
<div class="wave">
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
</div>
<div class="text">正在生成歌曲</div>
</div>
</template>
<style scoped lang="stylus">
.container {
display: flex;
flex-flow column
justify-content: center;
align-items: center;
margin: 0;
font-family: 'Arial', sans-serif;
color: #e1e1e1;
.wave {
display: flex;
justify-content: center;
align-items: flex-end;
height: 30px;
margin-bottom: 5px
.bar {
width: 8px;
margin: 0 2px;
background-color: #919191;
animation: wave 1.5s infinite;
}
.bar:nth-child(1) {
animation-delay: 0s;
}
.bar:nth-child(2) {
animation-delay: 0.1s;
}
.bar:nth-child(3) {
animation-delay: 0.2s;
}
.bar:nth-child(4) {
animation-delay: 0.3s;
}
.bar:nth-child(5) {
animation-delay: 0.4s;
}
.bar:nth-child(6) {
animation-delay: 0.5s;
}
.bar:nth-child(7) {
animation-delay: 0.6s;
}
.bar:nth-child(8) {
animation-delay: 0.7s;
}
.bar:nth-child(9) {
animation-delay: 0.8s;
}
.bar:nth-child(10) {
animation-delay: 0.9s;
}
}
}
@keyframes wave {
0%, 100% {
height: 10px;
}
50% {
height: 30px;
}
}
.text {
font-size: 14px;
}
</style>