|
|
@@ -7,54 +7,98 @@
|
|
|
<div class="message-content user-message chat" v-if="message.content">
|
|
|
<div v-html="message.content"></div>
|
|
|
</div>
|
|
|
- <div class="message-content ai-message chat" v-if="message.aiContent">
|
|
|
- <div v-html="message.aiContent"></div>
|
|
|
- <button class="confirm-btn" v-if="message.jsonData?.gType !== 'chat'" @click="generate(message)">确定</button>
|
|
|
+ <div class="message-content ai-message chat" v-if="message.aiContent || message.loading">
|
|
|
+ <div v-if="message.aiContent" v-html="message.aiContent"></div>
|
|
|
+ <svg v-else xmlns="http://www.w3.org/2000/svg" width="32" height="32"
|
|
|
+ viewBox="0 0 24 24"><!-- Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE -->
|
|
|
+ <circle cx="4" cy="12" r="3" fill="currentColor">
|
|
|
+ <animate id="svgSpinners3DotsBounce0" attributeName="cy"
|
|
|
+ begin="0;svgSpinners3DotsBounce1.end+0.25s" calcMode="spline" dur="0.6s"
|
|
|
+ keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12" />
|
|
|
+ </circle>
|
|
|
+ <circle cx="12" cy="12" r="3" fill="currentColor">
|
|
|
+ <animate attributeName="cy" begin="svgSpinners3DotsBounce0.begin+0.1s" calcMode="spline"
|
|
|
+ dur="0.6s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12" />
|
|
|
+ </circle>
|
|
|
+ <circle cx="20" cy="12" r="3" fill="currentColor">
|
|
|
+ <animate id="svgSpinners3DotsBounce1" attributeName="cy"
|
|
|
+ begin="svgSpinners3DotsBounce0.begin+0.2s" calcMode="spline" dur="0.6s"
|
|
|
+ keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12" />
|
|
|
+ </circle>
|
|
|
+ </svg>
|
|
|
+ <button class="confirm-btn" v-if="message.jsonData?.gType !== 'chat' && !message.chatloading && message.aiContent"
|
|
|
+ @click="generate(message)">{{ message.gLoading ? lang.ssLoading : lang.ssConfirm}}</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<!-- 输入区域 -->
|
|
|
<div class="input-section">
|
|
|
<div class="input-wrapper">
|
|
|
+ <div class="file-box" v-show="files.length">
|
|
|
+ <div v-for="(file, index) in files" :key="index" class="file-item">
|
|
|
+ <span class="file-name">{{ file.title }}</span>
|
|
|
+ <button class="remove-file-btn" @click="removeFile(index)">
|
|
|
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
+ <line x1="18" y1="6" x2="6" y2="18"></line>
|
|
|
+ <line x1="6" y1="6" x2="18" y2="18"></line>
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
<textarea class="ai-input"
|
|
|
- :placeholder="messages.length === 0 ? '例如:创建45分钟的四年级教学内容为水的三态变化的课程' : '输入 / 获取快捷操作短语'"
|
|
|
+ :placeholder="messages.length === 0 ? lang.ssAiChatExample : lang.ssAiChatShortcut"
|
|
|
v-model="inputText" @keyup.enter.exact="sendMessage" rows="5" />
|
|
|
<div class="input-actions">
|
|
|
- <button class="attach-btn" v-show="false">
|
|
|
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
- <path
|
|
|
- d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48">
|
|
|
- </path>
|
|
|
- </svg>
|
|
|
- </button>
|
|
|
- <button class="send-btn" @click="sendMessage">
|
|
|
+ <FileInput accept="*" @change="handleFileUpload" v-show="false">
|
|
|
+ <button class="attach-btn">
|
|
|
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
+ <path
|
|
|
+ d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48">
|
|
|
+ </path>
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </FileInput>
|
|
|
+ <button class="send-btn" @click="sendMessage" v-if="!chatLoading">
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
|
stroke-linecap="round" stroke-linejoin="round">
|
|
|
<line x1="22" y1="2" x2="11" y2="13"></line>
|
|
|
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
|
|
</svg>
|
|
|
</button>
|
|
|
+ <button class="send-btn stop" @click="stopMessage" v-if="chatLoading">
|
|
|
+ <svg width="32" height="32" viewBox="0 0 32 32"
|
|
|
+ fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
|
+ <rect width="32" height="32" rx="16" fill="black" fill-opacity="0.4"></rect>
|
|
|
+ <path
|
|
|
+ d="M11.3333 12.333C11.3333 11.7807 11.781 11.333 12.3333 11.333H19.6666C20.2189 11.333 20.6666 11.7807 20.6666 12.333V19.6663C20.6666 20.2186 20.2189 20.6663 19.6666 20.6663H12.3333C11.781 20.6663 11.3333 20.2186 11.3333 19.6663V12.333Z"
|
|
|
+ fill="white" fill-opacity="0.9"></path>
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
</div>
|
|
|
</div>
|
|
|
+ <!-- 输入时的快捷操作弹出 -->
|
|
|
+ <div class="quick-actions-popup" v-if="showQuickActions">
|
|
|
+ <button v-for="(action, index) in quickActions" :key="index" class="quick-action-btn" @click="sendQuickAction(action)">{{ action }}</button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
<!-- 初始状态 -->
|
|
|
<div class="initial-state" v-if="messages.length === 0">
|
|
|
<!-- 快捷操作 -->
|
|
|
<div class="quick-actions">
|
|
|
- <button class="quick-action-btn" @click="sendQuickAction('为当前页面内容生成2道选择题')">为当前页面内容生成2道选择题</button>
|
|
|
- <button class="quick-action-btn" @click="sendQuickAction('为当前页面生成2页内容页面')">为当前页面生成2页内容页面</button>
|
|
|
+ <button v-for="(action, index) in quickActions" :key="index" class="quick-action-btn" @click="sendQuickAction(action)">{{ action }}</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script lang="ts" setup>
|
|
|
-import { ref, onMounted, useTemplateRef, nextTick } from 'vue'
|
|
|
+import { ref, onMounted, useTemplateRef, nextTick, watch } from 'vue'
|
|
|
import { chat_no_stream, chat_stream, getAgentModel, chat_no_stream2 } from '@/tools/aiChat'
|
|
|
import { useSlidesStore } from '@/store'
|
|
|
import { lang } from '@/main'
|
|
|
import MarkdownIt from 'markdown-it'
|
|
|
import { getWorkPageId } from '@/services/course'
|
|
|
+import FileInput from '@/components/FileInput.vue'
|
|
|
|
|
|
interface ChatMessage {
|
|
|
uid?: string
|
|
|
@@ -62,6 +106,9 @@ interface ChatMessage {
|
|
|
content?: string
|
|
|
aiContent?: string
|
|
|
oldContent?: string
|
|
|
+ loading?: boolean
|
|
|
+ chatloading?: boolean
|
|
|
+ gLoading?: boolean
|
|
|
rawContent?: string
|
|
|
timestamp?: Date
|
|
|
like?: boolean
|
|
|
@@ -80,6 +127,11 @@ interface ChatMessage {
|
|
|
gType?: string
|
|
|
headUrl?: string
|
|
|
assistantName?: string
|
|
|
+ files?: Array<{
|
|
|
+ title: string
|
|
|
+ id?: string
|
|
|
+ url?: string
|
|
|
+ }>
|
|
|
sourceArray?: Array<{
|
|
|
text?: string
|
|
|
id?: string
|
|
|
@@ -100,15 +152,37 @@ const inputText = ref('')
|
|
|
const messages = ref<ChatMessage[]>([])
|
|
|
const chatSection = useTemplateRef<HTMLElement>('chatSection')
|
|
|
const chatLoading = ref(false)
|
|
|
+const showQuickActions = ref(false)
|
|
|
+const streamController = ref<{ abort: () => void } | null>(null)
|
|
|
+const noStreamController = ref<{ promise: Promise<string>; abort: () => void } | null>(null)
|
|
|
+const files = ref<Array<{ title: string; id?: string; url?: string }>>([])
|
|
|
+
|
|
|
+// 快捷操作短语数组
|
|
|
+const quickActions = [
|
|
|
+ lang.ssAiChatQuickAction1,
|
|
|
+ lang.ssAiChatQuickAction2,
|
|
|
+]
|
|
|
+
|
|
|
+// 监听输入变化,当输入"/"时显示快捷操作
|
|
|
+watch(inputText, (newValue) => {
|
|
|
+ if (messages.value.length > 0 && newValue === '/') {
|
|
|
+ showQuickActions.value = true
|
|
|
+ }
|
|
|
+ else if (newValue !== '/') {
|
|
|
+ showQuickActions.value = false
|
|
|
+ }
|
|
|
+})
|
|
|
|
|
|
const sendMessage = () => {
|
|
|
- if (inputText.value.trim()) {
|
|
|
+ if (chatLoading.value) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (inputText.value.trim() || files.value.length > 0) {
|
|
|
// 添加用户消息
|
|
|
messages.value.push({
|
|
|
role: 'user',
|
|
|
- content: inputText.value
|
|
|
+ content: inputText.value,
|
|
|
})
|
|
|
-
|
|
|
// 模拟AI回复
|
|
|
// setTimeout(() => {
|
|
|
|
|
|
@@ -121,12 +195,50 @@ const sendMessage = () => {
|
|
|
// }, 500)
|
|
|
prevChatResult()
|
|
|
messages.value.at(-1).loading = true
|
|
|
+ messages.value.at(-1).chatloading = true
|
|
|
chatLoading.value = true
|
|
|
sendAction(inputText.value)
|
|
|
inputText.value = ''
|
|
|
+ files.value = []
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+const stopMessage = () => {
|
|
|
+ if (streamController.value) {
|
|
|
+ streamController.value.abort()
|
|
|
+ streamController.value = null
|
|
|
+ }
|
|
|
+ if (noStreamController.value) {
|
|
|
+ noStreamController.value.abort()
|
|
|
+ noStreamController.value = null
|
|
|
+ }
|
|
|
+ chatLoading.value = false
|
|
|
+ if (messages.value.length > 0) {
|
|
|
+ messages.value.at(-1).chatloading = false
|
|
|
+ messages.value.at(-1).loading = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 处理文件上传
|
|
|
+const handleFileUpload = (files2) => {
|
|
|
+ const maxSize = 10 * 1024 * 1024 // 10MB
|
|
|
+ for (let i = 0; i < files2.length; i++) {
|
|
|
+ const file = files2[i]
|
|
|
+ if (file.size > maxSize) {
|
|
|
+ alert('文件大小不能超过10MB')
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ files.value.push({
|
|
|
+ title: file.name
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 移除文件
|
|
|
+const removeFile = (index: number) => {
|
|
|
+ files.value.splice(index, 1)
|
|
|
+}
|
|
|
+
|
|
|
const prevChatResult = () => {
|
|
|
nextTick(() => {
|
|
|
if (chatSection.value) {
|
|
|
@@ -136,17 +248,8 @@ const prevChatResult = () => {
|
|
|
}
|
|
|
|
|
|
const sendQuickAction = (action: string) => {
|
|
|
- // 添加用户消息
|
|
|
- messages.value.push({
|
|
|
- role: 'user',
|
|
|
- content: action
|
|
|
- })
|
|
|
-
|
|
|
- // 模拟AI回复
|
|
|
- // setTimeout(() => {
|
|
|
- // messages.value.at(-1).aiContent = '请问您想聚焦哪一重点?'
|
|
|
- // }, 500)
|
|
|
- sendAction(action)
|
|
|
+ inputText.value = action
|
|
|
+ sendMessage()
|
|
|
}
|
|
|
|
|
|
import { v4 as uuidv4 } from 'uuid'
|
|
|
@@ -157,7 +260,14 @@ const gType = ref('chat')
|
|
|
const sendAction = async (action: string) => {
|
|
|
const md = new MarkdownIt()
|
|
|
if (gType.value === 'chat') {
|
|
|
- const content = await chat_no_stream(action, agentid1.value, props.userid || '', lang.lang)
|
|
|
+ const result = chat_no_stream(action, agentid1.value, props.userid || '', lang.lang)
|
|
|
+ noStreamController.value = result
|
|
|
+ console.log(result)
|
|
|
+ const content = await result.promise
|
|
|
+ if (!content) {
|
|
|
+ stopMessage()
|
|
|
+ return
|
|
|
+ }
|
|
|
console.log(content)
|
|
|
// 渲染 Markdown 格式
|
|
|
try {
|
|
|
@@ -195,12 +305,16 @@ const sendAction = async (action: string) => {
|
|
|
}
|
|
|
else if (event.type === 'messageEnd') {
|
|
|
messages.value.at(-1).aiContent = md.render(event.data)
|
|
|
+ messages.value.at(-1).chatloading = false
|
|
|
chatLoading.value = false
|
|
|
prevChatResult()
|
|
|
}
|
|
|
- }, session_name.value).catch(err => {
|
|
|
+ }, session_name.value).then(controller => {
|
|
|
+ streamController.value = controller
|
|
|
+ }).catch(err => {
|
|
|
chatLoading.value = false
|
|
|
console.log('err', err)
|
|
|
+ stopMessage()
|
|
|
})
|
|
|
}
|
|
|
import useCreateElement from '@/hooks/useCreateElement'
|
|
|
@@ -209,51 +323,57 @@ const { createSlide } = useSlideHandler()
|
|
|
const { createFrameElement } = useCreateElement()
|
|
|
|
|
|
const generate = (message: ChatMessage) => {
|
|
|
+ if (message.gLoading) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ message.gLoading = true
|
|
|
if (message.jsonData?.gType === 'generate_choice_question') {
|
|
|
console.log(message.jsonData?.gType)
|
|
|
const prompt = [
|
|
|
- {
|
|
|
- role: 'user',
|
|
|
- content: `这是用户输入的内容:“${message.aiContent}”,根据用户输入的内容,生成选择题的json。输出一个json格式的回复,格式如下:{"testCount":1,"testTitle":"","testJson":[{"id":"7de1fdb4-bec3-4324-8986-4623f838e3d7","type":"2","teststitle":"1+1?","checkList":["1","2","3"],"timuList":[],"answer":[1],"userAnswer":[],"explanation":"解析"}]}。输出语言为${lang.lang === "en"? "英文" : lang.lang === "hk"? "繁体中文" : "简体中文"}`,
|
|
|
- },
|
|
|
- ];
|
|
|
- chat_no_stream2(prompt, { type: 'json_object' }).then(async (res: any) => {
|
|
|
- console.log('选择题', JSON.parse(res));
|
|
|
- gType.value = 'chat'
|
|
|
- setPageId(45, res).then(res => {
|
|
|
- let url = `https://beta.pbl.cocorobo.cn/pbl-teacher-table/dist/workPage.html#/setWorkPage?id=${res}&type=${45}`;
|
|
|
- createSlide()
|
|
|
- createFrameElement(url, 45)
|
|
|
- });
|
|
|
- });
|
|
|
+ {
|
|
|
+ role: 'user',
|
|
|
+ content: `这是用户输入的内容:“${message.aiContent}”,根据用户输入的内容,生成选择题的json。输出一个json格式的回复,格式如下:{"testCount":1,"testTitle":"","testJson":[{"id":"7de1fdb4-bec3-4324-8986-4623f838e3d7","type":"2","teststitle":"1+1?","checkList":["1","2","3"],"timuList":[],"answer":[1],"userAnswer":[],"explanation":"解析"}]}。输出语言为${lang.lang === 'en' ? '英文' : lang.lang === 'hk' ? '繁体中文' : '简体中文'}`,
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ chat_no_stream2(prompt, { type: 'json_object' }).then(async (res: any) => {
|
|
|
+ console.log('选择题', JSON.parse(res))
|
|
|
+ gType.value = 'chat'
|
|
|
+ setPageId(45, res).then(res => {
|
|
|
+ const url = `https://beta.pbl.cocorobo.cn/pbl-teacher-table/dist/workPage.html#/setWorkPage?id=${res}&type=${45}`
|
|
|
+ createSlide()
|
|
|
+ createFrameElement(url, 45)
|
|
|
+ message.gLoading = false
|
|
|
+ })
|
|
|
+ })
|
|
|
}
|
|
|
else if (message.jsonData?.gType === 'generate_qa') {
|
|
|
console.log(message.jsonData?.gType)
|
|
|
- const prompt = [
|
|
|
- {
|
|
|
- role: 'user',
|
|
|
- content: `这是用户输入的内容:“${message.aiContent}”,根据用户输入的内容,生成问答题的json。输出一个json格式的回复,格式如下:{"answerQ":"问题","answer":"","fileList":[],"imageList":[],"evaluationCriteria":"评价标准"}。输出语言为${lang.lang === "en"? "英文" : lang.lang === "hk"? "繁体中文" : "简体中文"}`,
|
|
|
- },
|
|
|
- ];
|
|
|
- chat_no_stream2(prompt, { type: 'json_object' }).then((res: any) => {
|
|
|
- console.log('问答题', JSON.parse(res));
|
|
|
- gType.value = 'chat'
|
|
|
- setPageId(15, res).then(res => {
|
|
|
- let url = `https://beta.pbl.cocorobo.cn/pbl-teacher-table/dist/workPage.html#/setWorkPage?id=${res}&type=${15}`;
|
|
|
- createSlide()
|
|
|
- createFrameElement(url, 15)
|
|
|
- });
|
|
|
- });
|
|
|
+ const prompt = [
|
|
|
+ {
|
|
|
+ role: 'user',
|
|
|
+ content: `这是用户输入的内容:“${message.aiContent}”,根据用户输入的内容,生成问答题的json。输出一个json格式的回复,格式如下:{"answerQ":"问题","answer":"","fileList":[],"imageList":[],"evaluationCriteria":"评价标准"}。输出语言为${lang.lang === 'en' ? '英文' : lang.lang === 'hk' ? '繁体中文' : '简体中文'}`,
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ chat_no_stream2(prompt, { type: 'json_object' }).then((res: any) => {
|
|
|
+ console.log('问答题', JSON.parse(res))
|
|
|
+ gType.value = 'chat'
|
|
|
+ setPageId(15, res).then(res => {
|
|
|
+ const url = `https://beta.pbl.cocorobo.cn/pbl-teacher-table/dist/workPage.html#/setWorkPage?id=${res}&type=${15}`
|
|
|
+ createSlide()
|
|
|
+ createFrameElement(url, 15)
|
|
|
+ message.gLoading = false
|
|
|
+ })
|
|
|
+ })
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const setPageId = async (tool: any, json: any) => {
|
|
|
- const res = await getWorkPageId({
|
|
|
- userid: props.userid || '',
|
|
|
- type: tool,
|
|
|
- json: json
|
|
|
- })
|
|
|
- return res[0][0].id
|
|
|
+ const res = await getWorkPageId({
|
|
|
+ userid: props.userid || '',
|
|
|
+ type: tool,
|
|
|
+ json: json
|
|
|
+ })
|
|
|
+ return res[0][0].id
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -283,6 +403,7 @@ onMounted(() => {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
gap: 12px;
|
|
|
+ position: relative;
|
|
|
}
|
|
|
|
|
|
.input-wrapper {
|
|
|
@@ -341,6 +462,57 @@ onMounted(() => {
|
|
|
color: #F78B22;
|
|
|
border-radius: 4px;
|
|
|
}
|
|
|
+
|
|
|
+ input[type="file"] {
|
|
|
+ display: none;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.file-box {
|
|
|
+ margin-bottom: 8px;
|
|
|
+ min-height: 24px;
|
|
|
+ max-height: 70px;
|
|
|
+ overflow-y: auto;
|
|
|
+
|
|
|
+ .file-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ background: #f5f5f5;
|
|
|
+ padding: 4px 8px;
|
|
|
+ border-radius: 4px;
|
|
|
+ margin-bottom: 4px;
|
|
|
+
|
|
|
+ .file-name {
|
|
|
+ flex: 1;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #374151;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ .remove-file-btn {
|
|
|
+ background: none;
|
|
|
+ border: none;
|
|
|
+ cursor: pointer;
|
|
|
+ color: #9CA3AF;
|
|
|
+ font-size: 12px;
|
|
|
+ padding: 2px;
|
|
|
+ margin-left: 8px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+
|
|
|
+ svg {
|
|
|
+ width: 14px;
|
|
|
+ height: 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ color: #EF4444;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
.send-btn {
|
|
|
@@ -365,6 +537,15 @@ onMounted(() => {
|
|
|
&:hover {
|
|
|
background: #E68A00;
|
|
|
}
|
|
|
+
|
|
|
+ &.stop {
|
|
|
+ background: unset;
|
|
|
+ width: auto;
|
|
|
+ svg {
|
|
|
+ width: 32px;
|
|
|
+ height: 32px;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
.quick-actions {
|
|
|
@@ -453,6 +634,11 @@ onMounted(() => {
|
|
|
background: #fafbfc;
|
|
|
border: 1.5px solid #e5e7eb;
|
|
|
border-bottom-left-radius: 2px;
|
|
|
+
|
|
|
+ &>svg {
|
|
|
+ width: 17px;
|
|
|
+ height: 17px;
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -500,6 +686,37 @@ ul {
|
|
|
margin: 4px 0;
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+.quick-actions-popup {
|
|
|
+ position: absolute;
|
|
|
+ bottom: 100%;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ background: white;
|
|
|
+ border: 1px solid #E5E7EB;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
|
+ padding: 8px;
|
|
|
+ z-index: 100;
|
|
|
+ margin-bottom: 8px;
|
|
|
+
|
|
|
+ .quick-action-btn {
|
|
|
+ width: 100%;
|
|
|
+ text-align: left;
|
|
|
+ padding: 10px 12px;
|
|
|
+ background: white;
|
|
|
+ border: none;
|
|
|
+ border-radius: 6px;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #374151;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: #F3F4F6;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
</style>
|
|
|
|
|
|
<style>
|