|
|
@@ -15,32 +15,28 @@
|
|
|
@keyup.enter.exact="doSendMessage(prompt.trim())"
|
|
|
></textarea>
|
|
|
<div class="dropdowns-container">
|
|
|
- <el-dropdown
|
|
|
+ <el-select
|
|
|
v-for="dropdown in dropdowns"
|
|
|
:key="dropdown.key"
|
|
|
v-model="dropdown.value"
|
|
|
- @command="(command) => handleSelect(dropdown.key, command)"
|
|
|
- @visible-change="(visible) => handleVisibleChange(dropdown.key, visible)"
|
|
|
+ @change="(value) => handleSelect(dropdown.key, value)"
|
|
|
+ filterable
|
|
|
+ class="custom-select"
|
|
|
+ :popper-append-to-body="false"
|
|
|
+ :teleported="false"
|
|
|
>
|
|
|
- <el-button type="primary">
|
|
|
- <el-icon class="el-icon--left"><component :is="dropdown.icon" /></el-icon>
|
|
|
- {{ dropdown.value }}
|
|
|
- <el-icon class="el-icon--right" v-if="!dropdown.visible"><ArrowDownBold /></el-icon>
|
|
|
- <el-icon class="el-icon--right" v-else><ArrowUpBold /></el-icon>
|
|
|
- </el-button>
|
|
|
- <template #dropdown>
|
|
|
- <el-dropdown-menu class="dropdown-menu">
|
|
|
- <el-dropdown-item
|
|
|
- v-for="item in dropdown.options"
|
|
|
- :key="item.value"
|
|
|
- :command="item.value"
|
|
|
- >{{ item.label }}</el-dropdown-item
|
|
|
- >
|
|
|
- </el-dropdown-menu>
|
|
|
+ <template #prefix>
|
|
|
+ <el-icon><component :is="dropdown.icon" /></el-icon>
|
|
|
</template>
|
|
|
- </el-dropdown>
|
|
|
+ <el-option
|
|
|
+ v-for="item in dropdown.options"
|
|
|
+ :key="item.value"
|
|
|
+ :label="item.label"
|
|
|
+ :value="item.value"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
</div>
|
|
|
- <button class="send-button" :class="{ 'active': prompt.trim().length > 0 }" @click="doSendMessage(prompt.trim())" :disabled="!prompt.trim().length">
|
|
|
+ <button class="send-button" :class="{ 'active': prompt.trim().length > 0 && (!showNewContainer || allStepsCompleted) }" @click="doSendMessage(prompt.trim())" :disabled="!prompt.trim().length || (showNewContainer && !allStepsCompleted)">
|
|
|
<Top />
|
|
|
</button>
|
|
|
</div>
|
|
|
@@ -66,12 +62,20 @@
|
|
|
<div class="progress-title">正在生成课程</div>
|
|
|
<div class="progress-steps">
|
|
|
<div class="progress-step" v-for="(step, index) in visibleProgressSteps" :key="index" :style="{ animationDelay: `${index * 0.2}s` }">
|
|
|
- <span class="step-text">{{ step.text }}</span>
|
|
|
- <span class="step-status" :class="{ 'completed': step.completed, 'active': step.active }">
|
|
|
- <span v-if="step.completed" class="checkmark">✓</span>
|
|
|
- <span v-else-if="step.active" class="loading-dot"></span>
|
|
|
- <span v-else class="step-placeholder"></span>
|
|
|
- </span>
|
|
|
+ <div class="step-header">
|
|
|
+ <span class="step-text">{{ step.text }}</span>
|
|
|
+ <span class="step-status" :class="{ 'completed': step.completed, 'active': step.active }">
|
|
|
+ <span v-if="step.completed" class="checkmark">✓</span>
|
|
|
+ <span v-else-if="step.active" class="loading-dot"></span>
|
|
|
+ <span v-else class="step-placeholder"></span>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="step-progress" v-if="(index === 2 || index === 3) && step.visible">
|
|
|
+ <div class="progress-bar">
|
|
|
+ <div class="progress-fill" :style="{ width: `${step.progress}%` }"></div>
|
|
|
+ </div>
|
|
|
+ <span class="progress-text">{{ step.progress }}%</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -462,6 +466,26 @@ const generateAllImages = async () => {
|
|
|
return new Promise(async (resolve, reject) => {
|
|
|
isGeneratingImages.value = true
|
|
|
try {
|
|
|
+ // 计算总图片数
|
|
|
+ progressCounters.value.images.total = 0
|
|
|
+ if (scriptData.coverImage && scriptData.coverImage.prompt && !scriptData.coverImage.url) {
|
|
|
+ progressCounters.value.images.total++
|
|
|
+ }
|
|
|
+ if (scriptData.lessons && scriptData.lessons.length > 0) {
|
|
|
+ for (let lessonIndex = 0; lessonIndex < scriptData.lessons.length; lessonIndex++) {
|
|
|
+ const lesson = scriptData.lessons[lessonIndex]
|
|
|
+ if (lesson && lesson.sections) {
|
|
|
+ for (let sectionIndex = 0; sectionIndex < lesson.sections.length; sectionIndex++) {
|
|
|
+ const section = lesson.sections[sectionIndex]
|
|
|
+ if (section && section.backgroundImage && section.backgroundImage.prompt && !section.backgroundImage.url) {
|
|
|
+ progressCounters.value.images.total++
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ progressCounters.value.images.current = 0
|
|
|
+
|
|
|
// 确保scriptData和coverImage存在
|
|
|
if (!scriptData.coverImage) {
|
|
|
scriptData.coverImage = { prompt: '', url: '', generating: false }
|
|
|
@@ -497,6 +521,7 @@ const generateAllImages = async () => {
|
|
|
// 检查是否有图片正在生成
|
|
|
if (Object.keys(inProgressImageMap.value).length === 0) {
|
|
|
// 没有图片需要生成,直接resolve
|
|
|
+ progressSteps.value[2].progress = 100
|
|
|
isGeneratingImages.value = false
|
|
|
resolve()
|
|
|
} else {
|
|
|
@@ -506,6 +531,7 @@ const generateAllImages = async () => {
|
|
|
// 检查是否有图片正在生成
|
|
|
const checkImagesComplete = setInterval(() => {
|
|
|
if (Object.keys(inProgressImageMap.value).length === 0) {
|
|
|
+ progressSteps.value[2].progress = 100
|
|
|
isGeneratingImages.value = false
|
|
|
clearInterval(checkImagesComplete)
|
|
|
stopImagePolling()
|
|
|
@@ -626,6 +652,7 @@ const refreshWatchImages = async () => {
|
|
|
try {
|
|
|
const list = await PaintingGetMys(imageIds)
|
|
|
const newWatchImages = {}
|
|
|
+ let completedCount = 0
|
|
|
// 遍历所有正在生成的图片
|
|
|
Object.keys(inProgressImageMap.value).forEach((key) => {
|
|
|
const imageId = Number(key)
|
|
|
@@ -638,6 +665,7 @@ const refreshWatchImages = async () => {
|
|
|
newWatchImages[key] = info
|
|
|
} else if (image.status === AiImageStatusEnum.SUCCESS && image.picUrl) {
|
|
|
// 图片生成成功
|
|
|
+ completedCount++
|
|
|
if (info.type === 'cover') {
|
|
|
// 处理封面图
|
|
|
scriptData.coverImage.url = image.picUrl
|
|
|
@@ -660,6 +688,7 @@ const refreshWatchImages = async () => {
|
|
|
console.log("【最终数据,生成图片成功】", scriptData)
|
|
|
} else if (image.status === AiImageStatusEnum.FAIL) {
|
|
|
// 图片生成失败
|
|
|
+ completedCount++
|
|
|
if (info.type === 'cover') {
|
|
|
// 处理封面图失败
|
|
|
scriptData.coverImage.generating = false
|
|
|
@@ -676,6 +705,7 @@ const refreshWatchImages = async () => {
|
|
|
}
|
|
|
} else {
|
|
|
// 图片不存在于返回列表中,可能已经处理完成或超时,从map中移除
|
|
|
+ completedCount++
|
|
|
if (info.type === 'cover') {
|
|
|
scriptData.coverImage.generating = false
|
|
|
} else {
|
|
|
@@ -690,6 +720,12 @@ const refreshWatchImages = async () => {
|
|
|
}
|
|
|
})
|
|
|
inProgressImageMap.value = newWatchImages
|
|
|
+
|
|
|
+ // 更新进度
|
|
|
+ progressCounters.value.images.current = completedCount
|
|
|
+ if (progressCounters.value.images.total > 0) {
|
|
|
+ progressSteps.value[2].progress = Math.round((completedCount / progressCounters.value.images.total) * 100)
|
|
|
+ }
|
|
|
} catch (error) {
|
|
|
console.error('轮询图片状态失败:', error)
|
|
|
}
|
|
|
@@ -699,6 +735,28 @@ const refreshWatchImages = async () => {
|
|
|
const generateAllVoiceovers = async () => {
|
|
|
isGeneratingVoiceovers.value = true
|
|
|
try {
|
|
|
+ // 计算总语音数
|
|
|
+ progressCounters.value.voiceovers.total = 0
|
|
|
+ if (scriptData.lessons && scriptData.lessons.length > 0) {
|
|
|
+ for (let lessonIndex = 0; lessonIndex < scriptData.lessons.length; lessonIndex++) {
|
|
|
+ const lesson = scriptData.lessons[lessonIndex]
|
|
|
+ if (lesson && lesson.sections) {
|
|
|
+ for (let sectionIndex = 0; sectionIndex < lesson.sections.length; sectionIndex++) {
|
|
|
+ const section = lesson.sections[sectionIndex]
|
|
|
+ if (section && section.dialogues) {
|
|
|
+ for (let dialogueIndex = 0; dialogueIndex < section.dialogues.length; dialogueIndex++) {
|
|
|
+ const dialogue = section.dialogues[dialogueIndex]
|
|
|
+ if (dialogue && dialogue.type !== 'user' && !dialogue.voiceoverUrl && dialogue.content) {
|
|
|
+ progressCounters.value.voiceovers.total++
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ progressCounters.value.voiceovers.current = 0
|
|
|
+
|
|
|
// 确保scriptData和lessons存在
|
|
|
if (scriptData.lessons && scriptData.lessons.length > 0) {
|
|
|
for (let lessonIndex = 0; lessonIndex < scriptData.lessons.length; lessonIndex++) {
|
|
|
@@ -710,8 +768,13 @@ const generateAllVoiceovers = async () => {
|
|
|
for (let dialogueIndex = 0; dialogueIndex < section.dialogues.length; dialogueIndex++) {
|
|
|
const dialogue = section.dialogues[dialogueIndex]
|
|
|
// 只处理没有语音URL且有内容的对话
|
|
|
- if (dialogue && !dialogue.url && dialogue.content) {
|
|
|
+ if (dialogue && dialogue.type !== 'user' && !dialogue.voiceoverUrl && dialogue.content) {
|
|
|
await generateVoiceover(lessonIndex, sectionIndex, dialogueIndex)
|
|
|
+ // 更新进度
|
|
|
+ progressCounters.value.voiceovers.current++
|
|
|
+ if (progressCounters.value.voiceovers.total > 0) {
|
|
|
+ progressSteps.value[3].progress = Math.round((progressCounters.value.voiceovers.current / progressCounters.value.voiceovers.total) * 100)
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -719,6 +782,8 @@ const generateAllVoiceovers = async () => {
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
+ // 完成后设置为100%
|
|
|
+ progressSteps.value[3].progress = 100
|
|
|
} catch (error) {
|
|
|
console.error('生成所有语音失败:', error)
|
|
|
} finally {
|
|
|
@@ -773,12 +838,18 @@ const showNewContainer = ref(false);
|
|
|
|
|
|
// 生成进度步骤
|
|
|
const progressSteps = ref([
|
|
|
- { text: '生成课程脚本...', completed: false, active: false, visible: false },
|
|
|
- { text: '生成课程小节...', completed: false, active: false, visible: false },
|
|
|
- { text: '生成背景图...', completed: false, active: false, visible: false },
|
|
|
- { text: '生成配音...', completed: false, active: false, visible: false }
|
|
|
+ { text: '生成课程脚本...', completed: false, active: false, visible: false, progress: 0 },
|
|
|
+ { text: '生成课程小节...', completed: false, active: false, visible: false, progress: 0 },
|
|
|
+ { text: '生成背景图...', completed: false, active: false, visible: false, progress: 0 },
|
|
|
+ { text: '生成配音...', completed: false, active: false, visible: false, progress: 0 }
|
|
|
]);
|
|
|
|
|
|
+// 进度计数器
|
|
|
+const progressCounters = ref({
|
|
|
+ images: { total: 0, current: 0 },
|
|
|
+ voiceovers: { total: 0, current: 0 }
|
|
|
+});
|
|
|
+
|
|
|
// 计算主讲下拉框选项
|
|
|
const teacherOptions = computed(() => {
|
|
|
const roles = localScriptRoles.value.length > 0 ? localScriptRoles.value : localScriptRoles.value;
|
|
|
@@ -797,6 +868,18 @@ const assistantOptions = computed(() => {
|
|
|
}));
|
|
|
});
|
|
|
|
|
|
+// 计算生成主题下拉框选项,基于 lessonList 动态生成
|
|
|
+const courseOptions = computed(() => {
|
|
|
+ // 提取 lessonList 中的课程名称
|
|
|
+ const lessonNames = lessonList.value.map(lesson => lesson.name).join('、');
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ label: `诗词课(${lessonNames})`,
|
|
|
+ value: '诗词课'
|
|
|
+ }
|
|
|
+ ];
|
|
|
+});
|
|
|
+
|
|
|
/**
|
|
|
* 获取角色列表
|
|
|
* @returns {Promise<void>}
|
|
|
@@ -824,9 +907,7 @@ const dropdowns = ref([
|
|
|
value: '生成主题',
|
|
|
visible: false,
|
|
|
icon: Search,
|
|
|
- options: [
|
|
|
- { label: '诗词课(课程引入、知识讲解、课程总结)', value: '诗词课' }
|
|
|
- ]
|
|
|
+ options: courseOptions.value
|
|
|
},
|
|
|
{
|
|
|
key: 'teacher',
|
|
|
@@ -955,6 +1036,8 @@ onMounted(async () => {
|
|
|
|
|
|
await getRoleList();
|
|
|
|
|
|
+ // 初始化生成主题下拉框选项
|
|
|
+ dropdowns.value[0].options = courseOptions.value;
|
|
|
// 初始化主讲下拉框选项
|
|
|
dropdowns.value[1].options = teacherOptions.value;
|
|
|
// 初始化助教下拉框选项
|
|
|
@@ -1067,83 +1150,110 @@ onMounted(async () => {
|
|
|
align-self: flex-start;
|
|
|
}
|
|
|
|
|
|
-.dropdowns-container .el-button {
|
|
|
- width: rpx(60);
|
|
|
+.dropdowns-container .custom-select {
|
|
|
+ width: auto;
|
|
|
height: rpx(18);
|
|
|
- background-color: #f1f0ff;
|
|
|
- border: rpx(1) solid #a7a4ed;
|
|
|
- color: black;
|
|
|
- border-radius: rpx(4);
|
|
|
font-size: rpx(8);
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
+ max-width: rpx(80);
|
|
|
}
|
|
|
|
|
|
+.dropdowns-container .custom-select :deep(.el-select__wrapper) {
|
|
|
+ background-color: #f1f0ff !important;
|
|
|
+ border: rpx(1) solid #a7a4ed !important;
|
|
|
+ border-radius: rpx(4) !important;
|
|
|
+ height: rpx(18) !important;
|
|
|
+}
|
|
|
|
|
|
-.dropdown-menu {
|
|
|
+.dropdowns-container .custom-select .el-select__input {
|
|
|
+ font-size: rpx(8);
|
|
|
+ color: black;
|
|
|
+ height: rpx(18);
|
|
|
+ line-height: rpx(18);
|
|
|
+}
|
|
|
+
|
|
|
+.dropdowns-container .custom-select .el-select__prefix {
|
|
|
+ color: black;
|
|
|
+}
|
|
|
+
|
|
|
+.dropdowns-container .custom-select .el-select__caret {
|
|
|
+ color: black;
|
|
|
+}
|
|
|
+
|
|
|
+/* 下拉菜单样式 */
|
|
|
+.dropdowns-container .custom-select :deep(.el-select-dropdown) {
|
|
|
min-width: rpx(50);
|
|
|
width: auto;
|
|
|
max-height: rpx(160);
|
|
|
overflow-y: auto;
|
|
|
border-radius: rpx(3);
|
|
|
border: 1px white solid;
|
|
|
- background-color: rgb(255, 255, 255, 0.5);
|
|
|
+ background-color: rgb(255, 255, 255, 0.8);
|
|
|
backdrop-filter: blur(rpx(5));
|
|
|
- box-shadow: 0 4px 8px rgba(202, 52, 52, 0.1);
|
|
|
+ // box-shadow: 0 4px 8px rgba(202, 52, 52, 0.1);
|
|
|
}
|
|
|
|
|
|
-.dropdown-menu ::v-deep::-webkit-scrollbar {
|
|
|
+.dropdowns-container .custom-select :deep(.el-select-dropdown::-webkit-scrollbar) {
|
|
|
width: rpx(2);
|
|
|
}
|
|
|
|
|
|
-.dropdown-menu ::v-deep::-webkit-scrollbar-track {
|
|
|
+.dropdowns-container .custom-select :deep(.el-select-dropdown::-webkit-scrollbar-track) {
|
|
|
background: #f1f1f1;
|
|
|
border-radius: rpx(2);
|
|
|
}
|
|
|
|
|
|
-.dropdown-menu ::v-deep::-webkit-scrollbar-thumb {
|
|
|
+.dropdowns-container .custom-select :deep(.el-select-dropdown::-webkit-scrollbar-thumb) {
|
|
|
background: #c1c1c1;
|
|
|
border-radius: rpx(2);
|
|
|
}
|
|
|
|
|
|
-.dropdown-menu ::v-deep::-webkit-scrollbar-thumb:hover {
|
|
|
+.dropdowns-container .custom-select :deep(.el-select-dropdown::-webkit-scrollbar-thumb:hover) {
|
|
|
background: #a8a8a8;
|
|
|
}
|
|
|
|
|
|
-.dropdown-menu ::v-deep(.el-dropdown-menu__item) {
|
|
|
- font-size: rpx(8);
|
|
|
- color: black;
|
|
|
- border-radius: rpx(5);
|
|
|
- width: auto;
|
|
|
- min-width: rpx(35);
|
|
|
- height: rpx(20);
|
|
|
- margin-bottom: rpx(8);
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- padding: 0 rpx(8);
|
|
|
+.dropdowns-container .custom-select :deep(.el-select-dropdown__item) {
|
|
|
+ font-size: rpx(8) !important;
|
|
|
+ color: black !important;
|
|
|
+ border-radius: rpx(5) !important;
|
|
|
+ width: auto !important;
|
|
|
+ min-width: rpx(35) !important;
|
|
|
+ height: rpx(20) !important;
|
|
|
+ margin-bottom: rpx(8) !important;
|
|
|
+ display: flex !important;
|
|
|
+ align-items: center !important;
|
|
|
+ justify-content: center !important;
|
|
|
+ padding: 0 rpx(8) !important;
|
|
|
}
|
|
|
|
|
|
-.dropdown-menu ::v-deep(.el-dropdown-menu__item:hover),
|
|
|
-.dropdown-menu ::v-deep(.el-dropdown-menu__item:focus),
|
|
|
-.dropdown-menu ::v-deep(.el-dropdown-menu__item:active) {
|
|
|
+.dropdowns-container .custom-select :deep(.el-select-dropdown__item:hover),
|
|
|
+.dropdowns-container .custom-select :deep(.el-select-dropdown__item:focus),
|
|
|
+.dropdowns-container .custom-select :deep(.el-select-dropdown__item.is-active) {
|
|
|
background: linear-gradient(
|
|
|
to bottom,
|
|
|
#fee78a,
|
|
|
#ffce1b
|
|
|
- );
|
|
|
- border: none;
|
|
|
- outline: none;
|
|
|
+ ) !important;
|
|
|
+ border: none !important;
|
|
|
+ outline: none !important;
|
|
|
+ color: black !important;
|
|
|
}
|
|
|
|
|
|
-/* 确保下拉按钮点击时也没有边框 */
|
|
|
-.dropdowns-container .el-button:focus,
|
|
|
-.dropdowns-container .el-button:active {
|
|
|
+/* 确保下拉框点击时也没有边框 */
|
|
|
+.dropdowns-container .custom-select:focus,
|
|
|
+.dropdowns-container .custom-select:active {
|
|
|
outline: none;
|
|
|
box-shadow: none;
|
|
|
}
|
|
|
|
|
|
+/* 搜索输入框样式 */
|
|
|
+.dropdowns-container .custom-select :deep(.el-select__input.is-focus) {
|
|
|
+ color: black;
|
|
|
+}
|
|
|
+
|
|
|
+/* 下拉框清空按钮样式 */
|
|
|
+.dropdowns-container .custom-select .el-select__clear {
|
|
|
+ color: black;
|
|
|
+}
|
|
|
+
|
|
|
/* 滚动条样式 */
|
|
|
.user-input-textarea::-webkit-scrollbar {
|
|
|
width: rpx(0);
|
|
|
@@ -1380,14 +1490,7 @@ onMounted(async () => {
|
|
|
}
|
|
|
|
|
|
.progress-step {
|
|
|
- display: flex;
|
|
|
- justify-content: flex-start;
|
|
|
- align-items: center;
|
|
|
- text-align: left;
|
|
|
- font-size: rpx(8);
|
|
|
- color: #666;
|
|
|
- padding: rpx(1) 0;
|
|
|
- transition: all 0.3s ease;
|
|
|
+ margin-bottom: rpx(5);
|
|
|
opacity: 0;
|
|
|
transform: translateY(rpx(5));
|
|
|
animation: stepEnter 0.5s ease forwards;
|
|
|
@@ -1405,6 +1508,18 @@ onMounted(async () => {
|
|
|
font-weight: bold;
|
|
|
}
|
|
|
|
|
|
+.step-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-start;
|
|
|
+ align-items: center;
|
|
|
+ text-align: left;
|
|
|
+ font-size: rpx(8);
|
|
|
+ color: #666;
|
|
|
+ padding: rpx(1) 0;
|
|
|
+ transition: all 0.3s ease;
|
|
|
+ margin-bottom: rpx(2);
|
|
|
+}
|
|
|
+
|
|
|
.step-text {
|
|
|
flex: 1;
|
|
|
overflow: hidden;
|
|
|
@@ -1453,9 +1568,37 @@ onMounted(async () => {
|
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
|
}
|
|
|
|
|
|
+.step-progress {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: rpx(5);
|
|
|
+ margin-left: rpx(0);
|
|
|
+}
|
|
|
+
|
|
|
+.progress-bar {
|
|
|
+ flex: 1;
|
|
|
+ height: rpx(2);
|
|
|
+ background-color: rgba(167, 164, 237, 0.2);
|
|
|
+ border-radius: rpx(1);
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.progress-fill {
|
|
|
+ height: 100%;
|
|
|
+ background-color: #a7a4ed;
|
|
|
+ transition: width 0.3s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.progress-text {
|
|
|
+ font-size: rpx(6);
|
|
|
+ color: #666;
|
|
|
+ min-width: rpx(20);
|
|
|
+ text-align: right;
|
|
|
+}
|
|
|
+
|
|
|
@keyframes pulse {
|
|
|
0%, 100% { opacity: 1; transform: scale(1); }
|
|
|
50% { opacity: 0.6; transform: scale(1.2); }
|
|
|
}
|
|
|
|
|
|
-</style>
|
|
|
+</style>
|