|
@@ -1,5 +1,5 @@
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
-import { ref, nextTick, onMounted, computed, onUnmounted, reactive, watch, isReactive } from 'vue'
|
|
|
|
|
|
+import { ref, nextTick, onMounted, computed, onUnmounted, reactive, isReactive } from 'vue'
|
|
import { Local } from '/@/utils/storage'
|
|
import { Local } from '/@/utils/storage'
|
|
import {
|
|
import {
|
|
User,
|
|
User,
|
|
@@ -40,10 +40,6 @@ const plugins: Array<MarkdownPlugin<any>> = [EChartsPlugin(), ToolsLoadingPlugin
|
|
// 消息列表
|
|
// 消息列表
|
|
const messages = ref<Message[]>([])
|
|
const messages = ref<Message[]>([])
|
|
|
|
|
|
-watch(messages, (newVal) => {
|
|
|
|
- console.log(newVal.map((it) => it.render_content))
|
|
|
|
-})
|
|
|
|
-
|
|
|
|
// 输入框内容
|
|
// 输入框内容
|
|
const inputMessage = ref('')
|
|
const inputMessage = ref('')
|
|
const messagesContainer = ref<HTMLElement>()
|
|
const messagesContainer = ref<HTMLElement>()
|
|
@@ -100,25 +96,16 @@ const messagesContainer = ref<HTMLElement>()
|
|
// },
|
|
// },
|
|
// ])
|
|
// ])
|
|
|
|
|
|
-const prompt = ref<string>('')
|
|
|
|
-const openPromptDialog = ref(false)
|
|
|
|
-
|
|
|
|
-// 提示词列表相关状态
|
|
|
|
-const promptList = ref<Prompt[]>([])
|
|
|
|
-const promptListParams = ref<PromptListParams>({
|
|
|
|
- pageNum: 1,
|
|
|
|
- pageSize: 10,
|
|
|
|
- keyWord: '',
|
|
|
|
-})
|
|
|
|
-const promptListTotal = ref(0)
|
|
|
|
-
|
|
|
|
// 模型选择
|
|
// 模型选择
|
|
const modelOptions = ref<LmConfigInfo[]>([])
|
|
const modelOptions = ref<LmConfigInfo[]>([])
|
|
const modelLabel = computed(() => {
|
|
const modelLabel = computed(() => {
|
|
|
|
+ if (!loadingModels.value && selectedModel.value === undefined) {
|
|
|
|
+ return '未配置模型'
|
|
|
|
+ }
|
|
const select = modelOptions.value.filter((i) => i.id === selectedModel.value)
|
|
const select = modelOptions.value.filter((i) => i.id === selectedModel.value)
|
|
|
|
|
|
if (select.length === 0) {
|
|
if (select.length === 0) {
|
|
- return '选择模型'
|
|
|
|
|
|
+ return '加载中'
|
|
}
|
|
}
|
|
|
|
|
|
return select[0].modelName
|
|
return select[0].modelName
|
|
@@ -131,22 +118,26 @@ const { loading: loadingModels, doLoading: loadModel } = useLoading(async () =>
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
|
|
- modelOptions.value = data.list
|
|
|
|
- selectedModel.value = data.list[0]?.id ?? undefined
|
|
|
|
|
|
+ modelOptions.value = data.list ?? []
|
|
|
|
+ selectedModel.value = modelOptions.value[0]?.id ?? undefined
|
|
})
|
|
})
|
|
|
|
|
|
// 词嵌入模型选择
|
|
// 词嵌入模型选择
|
|
const embeddingModelOptions = ref<LmConfigInfo[]>([])
|
|
const embeddingModelOptions = ref<LmConfigInfo[]>([])
|
|
const selectedEmbeddingModel = ref<number | undefined>(undefined)
|
|
const selectedEmbeddingModel = ref<number | undefined>(undefined)
|
|
const embeddingModelLabel = computed(() => {
|
|
const embeddingModelLabel = computed(() => {
|
|
|
|
+ if (!loadingEmbeddingModels.value && selectedEmbeddingModel.value === undefined) {
|
|
|
|
+ return '未配置词嵌入'
|
|
|
|
+ }
|
|
const select = embeddingModelOptions.value.filter((i) => i.id === selectedEmbeddingModel.value)
|
|
const select = embeddingModelOptions.value.filter((i) => i.id === selectedEmbeddingModel.value)
|
|
|
|
|
|
if (select.length === 0) {
|
|
if (select.length === 0) {
|
|
- return '选择词嵌入模型'
|
|
|
|
|
|
+ return '加载中'
|
|
}
|
|
}
|
|
|
|
|
|
return select[0].modelName
|
|
return select[0].modelName
|
|
})
|
|
})
|
|
|
|
+onMounted(loadModel)
|
|
|
|
|
|
const { loading: loadingEmbeddingModels, doLoading: loadEmbeddingModel } = useLoading(async () => {
|
|
const { loading: loadingEmbeddingModels, doLoading: loadEmbeddingModel } = useLoading(async () => {
|
|
const data: { list: LmConfigInfo[]; total: number } = await assist.model.getList({ modelType: 'embedding' }).catch(() => {
|
|
const data: { list: LmConfigInfo[]; total: number } = await assist.model.getList({ modelType: 'embedding' }).catch(() => {
|
|
@@ -155,44 +146,56 @@ const { loading: loadingEmbeddingModels, doLoading: loadEmbeddingModel } = useLo
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
|
|
- data.list = [
|
|
|
|
- ...(data.list ?? []),
|
|
|
|
- {
|
|
|
|
|
|
+ embeddingModelOptions.value = data.list ?? []
|
|
|
|
+ if (embeddingModelOptions.value.length !== 0) {
|
|
|
|
+ embeddingModelOptions.value.unshift({
|
|
id: -1,
|
|
id: -1,
|
|
modelName: '不启用词嵌入',
|
|
modelName: '不启用词嵌入',
|
|
status: true,
|
|
status: true,
|
|
- },
|
|
|
|
- ]
|
|
|
|
-
|
|
|
|
- embeddingModelOptions.value = data.list
|
|
|
|
- selectedEmbeddingModel.value = data.list[0]?.id ?? undefined
|
|
|
|
|
|
+ })
|
|
|
|
+ }
|
|
|
|
+ selectedEmbeddingModel.value = embeddingModelOptions.value[0]?.id ?? undefined
|
|
})
|
|
})
|
|
|
|
|
|
-onMounted(() => {
|
|
|
|
- loadModel()
|
|
|
|
- loadEmbeddingModel()
|
|
|
|
-})
|
|
|
|
|
|
+onMounted(loadEmbeddingModel)
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+// 提示词列表相关状态
|
|
|
|
+const promptList = ref<Prompt[]>([])
|
|
|
|
+const selectPromptId = ref<number|undefined>(undefined)
|
|
|
|
+const promptLabel = computed(() => {
|
|
|
|
+ if (!loadingPromptList.value && selectPromptId.value === undefined) {
|
|
|
|
+ return '未配置提示词'
|
|
|
|
+ }
|
|
|
|
+ const select = promptList.value.filter((i) => i.id === selectPromptId.value)
|
|
|
|
+
|
|
|
|
+ if (select.length === 0) {
|
|
|
|
+ return '加载中'
|
|
|
|
+ }
|
|
|
|
|
|
-// 加载提示词列表
|
|
|
|
|
|
+ return select[0].title
|
|
|
|
+})
|
|
const { loading: loadingPromptList, doLoading: loadPromptList } = useLoading(async () => {
|
|
const { loading: loadingPromptList, doLoading: loadPromptList } = useLoading(async () => {
|
|
- const data: { list: Prompt[]; total: number } = await assist.chat.prompt.list(promptListParams.value).catch(() => {
|
|
|
|
|
|
+ const data: { list: Prompt[]; total: number } = await assist.chat.prompt.list({pageSize:10,pageNum:1,keyWord:''}).catch(() => {
|
|
return {
|
|
return {
|
|
list: [],
|
|
list: [],
|
|
total: 0,
|
|
total: 0,
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
|
|
- promptList.value = data.list
|
|
|
|
- promptListTotal.value = data.total
|
|
|
|
-})
|
|
|
|
-
|
|
|
|
-// 监听对话框打开状态,打开时加载提示词列表
|
|
|
|
-watch(openPromptDialog, (newVal) => {
|
|
|
|
- if (newVal) {
|
|
|
|
- loadPromptList()
|
|
|
|
|
|
+ promptList.value = data.list ?? []
|
|
|
|
+ if (promptList.value.length !== 0) {
|
|
|
|
+ promptList.value.unshift({
|
|
|
|
+ id: -1,
|
|
|
|
+ title: '不启用提示词',
|
|
|
|
+ prompt: '',
|
|
|
|
+ })
|
|
}
|
|
}
|
|
|
|
+ selectPromptId.value = promptList.value[0]?.id ?? undefined
|
|
})
|
|
})
|
|
|
|
|
|
|
|
+onMounted(loadPromptList)
|
|
|
|
+
|
|
const selectedModel = ref<number | undefined>(undefined)
|
|
const selectedModel = ref<number | undefined>(undefined)
|
|
|
|
|
|
const chatInstance = ref<(() => void) | undefined>(undefined)
|
|
const chatInstance = ref<(() => void) | undefined>(undefined)
|
|
@@ -320,21 +323,22 @@ const replaceMessage = async (index: number) => {
|
|
}
|
|
}
|
|
|
|
|
|
const chatInternal = (rtn: Message, context: Message[] = messages.value) => {
|
|
const chatInternal = (rtn: Message, context: Message[] = messages.value) => {
|
|
|
|
+ let r = [...context]
|
|
|
|
+ if (selectPromptId.value != undefined) {
|
|
|
|
+ const prompt = promptList.value.find((i) => i.id === selectPromptId.value)?.prompt ?? ''
|
|
|
|
+ //insert element on top
|
|
|
|
+ r.unshift({
|
|
|
|
+ id: messages.value.length,
|
|
|
|
+ role: 'system',
|
|
|
|
+ render_content: prompt,
|
|
|
|
+ content: prompt,
|
|
|
|
+ timestamp: Date.now(),
|
|
|
|
+ })
|
|
|
|
+ }
|
|
chatInstance.value = assist.chat.sse({
|
|
chatInstance.value = assist.chat.sse({
|
|
chatRequest: {
|
|
chatRequest: {
|
|
session_id: activeConversationId.value!,
|
|
session_id: activeConversationId.value!,
|
|
- message: prompt.value
|
|
|
|
- ? [
|
|
|
|
- {
|
|
|
|
- id: messages.value.length,
|
|
|
|
- role: 'system',
|
|
|
|
- render_content: prompt.value,
|
|
|
|
- content: prompt.value,
|
|
|
|
- timestamp: Date.now(),
|
|
|
|
- },
|
|
|
|
- ...context,
|
|
|
|
- ]
|
|
|
|
- : context,
|
|
|
|
|
|
+ message: r,
|
|
modelClassId: selectedModel.value,
|
|
modelClassId: selectedModel.value,
|
|
modelEmbeddingId: (selectedEmbeddingModel.value ?? -1) > 0 ? selectedEmbeddingModel.value : undefined,
|
|
modelEmbeddingId: (selectedEmbeddingModel.value ?? -1) > 0 ? selectedEmbeddingModel.value : undefined,
|
|
},
|
|
},
|
|
@@ -604,20 +608,14 @@ const startMultidelete = async () => {
|
|
|
|
|
|
// 创建新对话
|
|
// 创建新对话
|
|
const { loading: creatingConversation, doLoading: createConversationAndSetItActive } = useLoading(async () => {
|
|
const { loading: creatingConversation, doLoading: createConversationAndSetItActive } = useLoading(async () => {
|
|
- try {
|
|
|
|
- // 调用API创建新对话,默认标题为"新对话"
|
|
|
|
- const { id } = await assist.session.add('新对话')
|
|
|
|
- // 刷新对话列表
|
|
|
|
- await doLoadConversations()
|
|
|
|
-
|
|
|
|
- await nextTick()
|
|
|
|
- activeConversationId.value = id
|
|
|
|
- messages.value = []
|
|
|
|
- } catch (error) {
|
|
|
|
- console.error('创建对话失败:', error)
|
|
|
|
- // 可以在这里添加错误提示
|
|
|
|
- throw error
|
|
|
|
- }
|
|
|
|
|
|
+ // 调用API创建新对话,默认标题为"新对话"
|
|
|
|
+ const { id } = await assist.session.add('新对话')
|
|
|
|
+ // 刷新对话列表
|
|
|
|
+ await doLoadConversations()
|
|
|
|
+
|
|
|
|
+ await nextTick()
|
|
|
|
+ activeConversationId.value = id
|
|
|
|
+ messages.value = []
|
|
})
|
|
})
|
|
|
|
|
|
// 编辑会话状态管理
|
|
// 编辑会话状态管理
|
|
@@ -814,38 +812,6 @@ const { loading: exportConversationLoading, doLoading: exportConversation } = us
|
|
const isBlank = (str: string) => {
|
|
const isBlank = (str: string) => {
|
|
return str == null || str.trim().length === 0
|
|
return str == null || str.trim().length === 0
|
|
}
|
|
}
|
|
-
|
|
|
|
-// 提示词列表相关函数
|
|
|
|
-// 选择提示词
|
|
|
|
-const selectPrompt = (selectedPrompt: Prompt) => {
|
|
|
|
- prompt.value = selectedPrompt.prompt
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
-// 提示词分页变化
|
|
|
|
-const handlePromptPageChange = async (page: number) => {
|
|
|
|
- promptListParams.value.pageNum = page
|
|
|
|
- await loadPromptList()
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
-// 提示词每页数目变化
|
|
|
|
-const handlePromptPageSizeChange = async (pageSize: number) => {
|
|
|
|
- promptListParams.value.pageSize = pageSize
|
|
|
|
- promptListParams.value.pageNum = 1 // 重置到第一页
|
|
|
|
- await loadPromptList()
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
-// 提示词搜索
|
|
|
|
-const handlePromptSearch = async () => {
|
|
|
|
- promptListParams.value.pageNum = 1
|
|
|
|
- await loadPromptList()
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
-// 重置提示词搜索
|
|
|
|
-const handlePromptReset = async () => {
|
|
|
|
- promptListParams.value.keyWord = ''
|
|
|
|
- promptListParams.value.pageNum = 1
|
|
|
|
- await loadPromptList()
|
|
|
|
-}
|
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
<template>
|
|
<template>
|
|
@@ -1150,9 +1116,12 @@ const handlePromptReset = async () => {
|
|
<!-- 左下角按钮组 -->
|
|
<!-- 左下角按钮组 -->
|
|
<div class="left-controls">
|
|
<div class="left-controls">
|
|
<!-- 模型选择按钮 -->
|
|
<!-- 模型选择按钮 -->
|
|
- <el-dropdown trigger="click" placement="top-start">
|
|
|
|
|
|
+ <el-dropdown trigger="click" placement="top-start" :disabled="loadingModels || modelLabel == '未配置模型'">
|
|
<button class="control-btn model-btn">
|
|
<button class="control-btn model-btn">
|
|
- <el-icon><Setting /></el-icon>
|
|
|
|
|
|
+ <el-icon>
|
|
|
|
+ <Setting v-if="!loadingModels"/>
|
|
|
|
+ <Loading v-else class="spin"/>
|
|
|
|
+ </el-icon>
|
|
<span>{{ modelLabel }}</span>
|
|
<span>{{ modelLabel }}</span>
|
|
</button>
|
|
</button>
|
|
<template #dropdown>
|
|
<template #dropdown>
|
|
@@ -1170,9 +1139,12 @@ const handlePromptReset = async () => {
|
|
</el-dropdown>
|
|
</el-dropdown>
|
|
|
|
|
|
<!-- 词嵌入模型选择按钮 -->
|
|
<!-- 词嵌入模型选择按钮 -->
|
|
- <el-dropdown trigger="click" placement="top-start">
|
|
|
|
|
|
+ <el-dropdown trigger="click" placement="top-start" :disabled="loadingModels || embeddingModelLabel == '未配置词嵌入'">
|
|
<button class="control-btn embedding-model-btn">
|
|
<button class="control-btn embedding-model-btn">
|
|
- <el-icon><CopyDocument /></el-icon>
|
|
|
|
|
|
+ <el-icon>
|
|
|
|
+ <CopyDocument v-if="!loadingEmbeddingModels"/>
|
|
|
|
+ <Loading v-else class="spin"/>
|
|
|
|
+ </el-icon>
|
|
<span>{{ embeddingModelLabel }}</span>
|
|
<span>{{ embeddingModelLabel }}</span>
|
|
</button>
|
|
</button>
|
|
<template #dropdown>
|
|
<template #dropdown>
|
|
@@ -1189,11 +1161,28 @@ const handlePromptReset = async () => {
|
|
</template>
|
|
</template>
|
|
</el-dropdown>
|
|
</el-dropdown>
|
|
|
|
|
|
- <!-- 提示词选择按钮 -->
|
|
|
|
- <button class="control-btn prompt-btn" @click="openPromptDialog = true">
|
|
|
|
- <el-icon><Document /></el-icon>
|
|
|
|
- <span v-if="prompt">提示词:{{ prompt.length }}字</span>
|
|
|
|
- </button>
|
|
|
|
|
|
+ <!-- 词嵌入模型选择按钮 -->
|
|
|
|
+ <el-dropdown trigger="click" placement="top-start" :disabled="loadingModels || promptLabel == '未配置提示词'">
|
|
|
|
+ <button class="control-btn embedding-model-btn">
|
|
|
|
+ <el-icon>
|
|
|
|
+ <CopyDocument v-if="!loadingPromptList"/>
|
|
|
|
+ <Loading v-else class="spin"/>
|
|
|
|
+ </el-icon>
|
|
|
|
+ <span>{{ promptLabel }}</span>
|
|
|
|
+ </button>
|
|
|
|
+ <template #dropdown>
|
|
|
|
+ <el-dropdown-menu>
|
|
|
|
+ <el-dropdown-item
|
|
|
|
+ v-for="item in promptList"
|
|
|
|
+ :key="item.id"
|
|
|
|
+ @click="selectPromptId = item.id"
|
|
|
|
+ :class="{ 'is-selected': selectPromptId === item.id }"
|
|
|
|
+ >
|
|
|
|
+ {{ item.title }}
|
|
|
|
+ </el-dropdown-item>
|
|
|
|
+ </el-dropdown-menu>
|
|
|
|
+ </template>
|
|
|
|
+ </el-dropdown>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- 右下角按钮组 -->
|
|
<!-- 右下角按钮组 -->
|
|
@@ -1252,84 +1241,6 @@ const handlePromptReset = async () => {
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</el-main>
|
|
</el-main>
|
|
-
|
|
|
|
- <!-- 提示词设置对话框 -->
|
|
|
|
- <el-dialog
|
|
|
|
- v-model="openPromptDialog"
|
|
|
|
- title="设置提示词"
|
|
|
|
- width="800px"
|
|
|
|
- :before-close="
|
|
|
|
- () => {
|
|
|
|
- openPromptDialog = false
|
|
|
|
- }
|
|
|
|
- "
|
|
|
|
- >
|
|
|
|
- <div class="prompt-dialog-content">
|
|
|
|
- <!-- 提示词输入框 -->
|
|
|
|
- <div class="prompt-input-section">
|
|
|
|
- <h4>提示词内容</h4>
|
|
|
|
- <el-input v-model="prompt" type="textarea" placeholder="请输入提示词..." :rows="8" resize="none" />
|
|
|
|
- </div>
|
|
|
|
-
|
|
|
|
- <!-- 提示词列表 -->
|
|
|
|
- <div class="prompt-list-section">
|
|
|
|
- <div class="prompt-list-header">
|
|
|
|
- <h4>选择提示词模板</h4>
|
|
|
|
- <div class="prompt-search">
|
|
|
|
- <el-input
|
|
|
|
- v-model="promptListParams.keyWord"
|
|
|
|
- placeholder="搜索提示词..."
|
|
|
|
- :prefix-icon="Search"
|
|
|
|
- clearable
|
|
|
|
- @keydown.enter="handlePromptSearch"
|
|
|
|
- @clear="handlePromptReset"
|
|
|
|
- style="width: 200px"
|
|
|
|
- />
|
|
|
|
- <el-button type="primary" :icon="Search" @click="handlePromptSearch" :loading="loadingPromptList" size="small"> 搜索 </el-button>
|
|
|
|
- </div>
|
|
|
|
- </div>
|
|
|
|
-
|
|
|
|
- <!-- 提示词列表 -->
|
|
|
|
- <div class="prompt-list-container" v-loading="loadingPromptList">
|
|
|
|
- <div v-if="promptList.length === 0" class="prompt-list-empty">
|
|
|
|
- <el-empty description="暂无提示词模板" />
|
|
|
|
- </div>
|
|
|
|
- <div v-else class="prompt-list">
|
|
|
|
- <div v-for="item in promptList" :key="item.id" class="prompt-item" @click="selectPrompt(item)">
|
|
|
|
- <div class="prompt-item-header">
|
|
|
|
- <span class="prompt-title">{{ item.title }}</span>
|
|
|
|
- </div>
|
|
|
|
- <div class="prompt-preview">
|
|
|
|
- {{ item.prompt.length > 60 ? item.prompt.substring(0, 60) + '...' : item.prompt }}
|
|
|
|
- </div>
|
|
|
|
- </div>
|
|
|
|
- </div>
|
|
|
|
- </div>
|
|
|
|
-
|
|
|
|
- <!-- 分页 -->
|
|
|
|
- <div class="prompt-pagination" v-if="promptListTotal > 0">
|
|
|
|
- <el-pagination
|
|
|
|
- v-model:current-page="promptListParams.pageNum"
|
|
|
|
- v-model:page-size="promptListParams.pageSize"
|
|
|
|
- :total="promptListTotal"
|
|
|
|
- :page-sizes="[5, 10, 20, 30]"
|
|
|
|
- layout="total, sizes, prev, pager, next"
|
|
|
|
- @current-change="handlePromptPageChange"
|
|
|
|
- @size-change="handlePromptPageSizeChange"
|
|
|
|
- :disabled="loadingPromptList"
|
|
|
|
- small
|
|
|
|
- />
|
|
|
|
- </div>
|
|
|
|
- </div>
|
|
|
|
- </div>
|
|
|
|
-
|
|
|
|
- <template #footer>
|
|
|
|
- <div class="dialog-footer">
|
|
|
|
- <el-button @click="openPromptDialog = false">取消</el-button>
|
|
|
|
- <el-button type="primary" @click="openPromptDialog = false">确定</el-button>
|
|
|
|
- </div>
|
|
|
|
- </template>
|
|
|
|
- </el-dialog>
|
|
|
|
</el-container>
|
|
</el-container>
|
|
</template>
|
|
</template>
|
|
|
|
|
|
@@ -2311,4 +2222,38 @@ const handlePromptReset = async () => {
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
+
|
|
|
|
+// 无限旋转动画
|
|
|
|
+@keyframes spin {
|
|
|
|
+ to {
|
|
|
|
+ transform: rotate(360deg);
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// 基础旋转类
|
|
|
|
+.spin {
|
|
|
|
+ display: inline-block;
|
|
|
|
+ animation: spin 1s linear infinite;
|
|
|
|
+ transform-origin: center center;
|
|
|
|
+
|
|
|
|
+ // 变体:慢速
|
|
|
|
+ &-slow {
|
|
|
|
+ animation-duration: 3s;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 变体:快速
|
|
|
|
+ &-fast {
|
|
|
|
+ animation-duration: 0.6s;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 变体:反向
|
|
|
|
+ &-rev {
|
|
|
|
+ animation-direction: reverse;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 变体:悬停暂停
|
|
|
|
+ &-pause:hover {
|
|
|
|
+ animation-play-state: paused;
|
|
|
|
+ }
|
|
|
|
+}
|
|
</style>
|
|
</style>
|