feat: 增加多对话流处理
This commit is contained in:
parent
535eefa8de
commit
4bf59fe6e0
|
|
@ -3,3 +3,6 @@ import { createPinia } from 'pinia'
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
|
|
||||||
export default pinia
|
export default pinia
|
||||||
|
|
||||||
|
// 导出 store 供其他地方使用
|
||||||
|
export { useStreamStore } from './streamStore.js'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
/**
|
||||||
|
* StreamManager - 管理多个并发 SSE 流
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. 同时管理多个会话的流式请求
|
||||||
|
* 2. 支持取消、重试等操作
|
||||||
|
* 3. 与 Pinia store 集成
|
||||||
|
*/
|
||||||
|
import { useStreamStore } from './streamStore.js'
|
||||||
|
|
||||||
|
class StreamManager {
|
||||||
|
constructor() {
|
||||||
|
// 存储所有活跃的流:{ conversationId: { abort, promise } }
|
||||||
|
this.activeStreams = {}
|
||||||
|
// SSE 解码器
|
||||||
|
this.decoder = new TextDecoder()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动一个新的流
|
||||||
|
* @param {string} conversationId - 会话 ID
|
||||||
|
* @param {object} data - 请求数据
|
||||||
|
* @param {string} userMessageId - 用户消息 ID
|
||||||
|
*/
|
||||||
|
async startStream(conversationId, data, userMessageId) {
|
||||||
|
const streamStore = useStreamStore()
|
||||||
|
|
||||||
|
// 如果该会话已有活跃流,先取消
|
||||||
|
if (this.activeStreams[conversationId]) {
|
||||||
|
this.cancelStream(conversationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
this.activeStreams[conversationId] = { controller }
|
||||||
|
|
||||||
|
// 初始化 store 中的流状态
|
||||||
|
streamStore.initStream(conversationId, userMessageId)
|
||||||
|
|
||||||
|
const promise = this._executeStream(conversationId, data, controller.signal)
|
||||||
|
this.activeStreams[conversationId].promise = promise
|
||||||
|
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 SSE 流
|
||||||
|
*/
|
||||||
|
async _executeStream(conversationId, data, signal) {
|
||||||
|
const streamStore = useStreamStore()
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/messages/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
conversation_id: data.conversation_id,
|
||||||
|
content: data.content,
|
||||||
|
thinking_enabled: data.thinking_enabled || false,
|
||||||
|
enabled_tools: data.enabled_tools || []
|
||||||
|
}),
|
||||||
|
signal
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(err.message || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
let buffer = ''
|
||||||
|
let completed = false
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
buffer += this.decoder.decode(value, { stream: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
// 处理 buffer 中剩余的数据
|
||||||
|
this._processBuffer(conversationId, buffer, streamStore, () => {
|
||||||
|
completed = true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!completed) {
|
||||||
|
streamStore.errorStream(conversationId, 'stream ended without done event')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
this._processLines(conversationId, lines, streamStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流结束但没有收到 done 事件
|
||||||
|
if (!completed) {
|
||||||
|
streamStore.errorStream(conversationId, 'stream ended unexpectedly')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name !== 'AbortError') {
|
||||||
|
console.error('Stream error:', e)
|
||||||
|
streamStore.errorStream(conversationId, e.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 清理活跃流记录
|
||||||
|
delete this.activeStreams[conversationId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理缓冲区
|
||||||
|
*/
|
||||||
|
_processBuffer(conversationId, buffer, streamStore, onComplete) {
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
let currentEvent = ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event: ')) {
|
||||||
|
currentEvent = line.slice(7).trim()
|
||||||
|
} else if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.slice(6))
|
||||||
|
this._handleEvent(conversationId, currentEvent, data, streamStore, onComplete)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('SSE parse error:', e, 'line:', line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理行
|
||||||
|
*/
|
||||||
|
_processLines(conversationId, lines, streamStore) {
|
||||||
|
let currentEvent = ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event: ')) {
|
||||||
|
currentEvent = line.slice(7).trim()
|
||||||
|
} else if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.slice(6))
|
||||||
|
this._handleEvent(conversationId, currentEvent, data, streamStore, null)
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理事件
|
||||||
|
*/
|
||||||
|
_handleEvent(conversationId, eventType, data, streamStore, onComplete) {
|
||||||
|
switch (eventType) {
|
||||||
|
case 'process_step':
|
||||||
|
streamStore.updateStep(conversationId, data.step)
|
||||||
|
break
|
||||||
|
case 'done':
|
||||||
|
streamStore.completeStream(conversationId, data)
|
||||||
|
if (onComplete) onComplete()
|
||||||
|
break
|
||||||
|
case 'error':
|
||||||
|
streamStore.errorStream(conversationId, data.content)
|
||||||
|
if (onComplete) onComplete()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消指定会话的流
|
||||||
|
*/
|
||||||
|
cancelStream(conversationId) {
|
||||||
|
const stream = this.activeStreams[conversationId]
|
||||||
|
if (stream) {
|
||||||
|
stream.controller.abort()
|
||||||
|
delete this.activeStreams[conversationId]
|
||||||
|
}
|
||||||
|
// 清除 store 中的流状态
|
||||||
|
const streamStore = useStreamStore()
|
||||||
|
streamStore.clearStream(conversationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消所有流
|
||||||
|
*/
|
||||||
|
cancelAll() {
|
||||||
|
Object.keys(this.activeStreams).forEach(conversationId => {
|
||||||
|
this.cancelStream(conversationId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查指定会话是否有活跃流
|
||||||
|
*/
|
||||||
|
hasActiveStream(conversationId) {
|
||||||
|
return !!this.activeStreams[conversationId]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取活跃流数量
|
||||||
|
*/
|
||||||
|
getActiveCount() {
|
||||||
|
return Object.keys(this.activeStreams).length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const streamManager = new StreamManager()
|
||||||
|
export default streamManager
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
/**
|
||||||
|
* 流状态管理 Store
|
||||||
|
* 管理多个会话的并发流式消息状态
|
||||||
|
*/
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
export const useStreamStore = defineStore('stream', () => {
|
||||||
|
// 存储所有活跃的流状态:{ conversationId: StreamState }
|
||||||
|
const streams = ref({})
|
||||||
|
|
||||||
|
// 获取指定会话的流状态
|
||||||
|
const getStreamState = (conversationId) => {
|
||||||
|
return streams.value[conversationId] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查指定会话是否有活跃的流
|
||||||
|
const hasActiveStream = (conversationId) => {
|
||||||
|
const state = streams.value[conversationId]
|
||||||
|
return state && state.status === 'streaming'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有有活跃流的会话 ID
|
||||||
|
const activeConversationIds = computed(() => {
|
||||||
|
return Object.keys(streams.value).filter(id =>
|
||||||
|
streams.value[id]?.status === 'streaming'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化流状态
|
||||||
|
const initStream = (conversationId, userMessageId) => {
|
||||||
|
streams.value[conversationId] = {
|
||||||
|
id: userMessageId,
|
||||||
|
status: 'streaming', // streaming, done, error
|
||||||
|
process_steps: [],
|
||||||
|
token_count: 0,
|
||||||
|
usage: null,
|
||||||
|
error: null,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
completed_at: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新步骤(追加或更新)
|
||||||
|
const updateStep = (conversationId, step) => {
|
||||||
|
const state = streams.value[conversationId]
|
||||||
|
if (!state) return
|
||||||
|
|
||||||
|
const idx = state.process_steps.findIndex(s => s.id === step.id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
state.process_steps[idx] = step
|
||||||
|
} else {
|
||||||
|
state.process_steps.push(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成流
|
||||||
|
const completeStream = (conversationId, data) => {
|
||||||
|
const state = streams.value[conversationId]
|
||||||
|
if (!state) return
|
||||||
|
|
||||||
|
state.status = 'done'
|
||||||
|
state.token_count = data.token_count || 0
|
||||||
|
state.usage = data.usage || null
|
||||||
|
state.completed_at = new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流错误
|
||||||
|
const errorStream = (conversationId, error) => {
|
||||||
|
const state = streams.value[conversationId]
|
||||||
|
if (!state) return
|
||||||
|
|
||||||
|
state.status = 'error'
|
||||||
|
state.error = error
|
||||||
|
state.completed_at = new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除流状态
|
||||||
|
const clearStream = (conversationId) => {
|
||||||
|
delete streams.value[conversationId]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量设置流状态(用于恢复)
|
||||||
|
const setStreamState = (conversationId, state) => {
|
||||||
|
streams.value[conversationId] = state
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
streams,
|
||||||
|
getStreamState,
|
||||||
|
hasActiveStream,
|
||||||
|
activeConversationIds,
|
||||||
|
initStream,
|
||||||
|
updateStep,
|
||||||
|
completeStream,
|
||||||
|
errorStream,
|
||||||
|
clearStream,
|
||||||
|
setStreamState
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default useStreamStore
|
||||||
|
|
@ -49,13 +49,13 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 流式消息 -->
|
<!-- 流式消息 - 使用 store 中的状态 -->
|
||||||
<div v-if="streamingMessage" class="message-bubble assistant streaming">
|
<div v-if="currentStreamState" class="message-bubble assistant streaming">
|
||||||
<div class="avatar">Luxx</div>
|
<div class="avatar">Luxx</div>
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
<ProcessBlock
|
<ProcessBlock
|
||||||
:process-steps="streamingMessage.process_steps"
|
:process-steps="currentStreamState.process_steps"
|
||||||
:streaming="true"
|
:streaming="currentStreamState.status === 'streaming'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -92,28 +92,41 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { conversationsAPI, messagesAPI, toolsAPI } from '../utils/api.js'
|
import { conversationsAPI, messagesAPI, toolsAPI } from '../utils/api.js'
|
||||||
|
import { streamManager } from '../utils/streamManager.js'
|
||||||
|
import { useStreamStore } from '../utils/streamStore.js'
|
||||||
import ProcessBlock from '../components/ProcessBlock.vue'
|
import ProcessBlock from '../components/ProcessBlock.vue'
|
||||||
import MessageBubble from '../components/MessageBubble.vue'
|
import MessageBubble from '../components/MessageBubble.vue'
|
||||||
import { renderMarkdown } from '../utils/markdown.js'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const streamStore = useStreamStore()
|
||||||
|
|
||||||
const messages = ref([])
|
const messages = ref([])
|
||||||
const inputMessage = ref('')
|
const inputMessage = ref('')
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const sending = ref(false)
|
|
||||||
const streamingMessage = ref(null)
|
|
||||||
const messagesContainer = ref(null)
|
const messagesContainer = ref(null)
|
||||||
const textareaRef = ref(null)
|
const textareaRef = ref(null)
|
||||||
const autoScroll = ref(true)
|
const autoScroll = ref(true)
|
||||||
const conversationId = ref(route.params.id)
|
const conversationId = ref(route.params.id)
|
||||||
const conversationTitle = ref('')
|
const conversationTitle = ref('')
|
||||||
const enabledTools = ref([]) // 启用的工具名称列表
|
const enabledTools = ref([])
|
||||||
|
|
||||||
const canSend = computed(() => inputMessage.value.trim().length > 0)
|
const canSend = computed(() => inputMessage.value.trim().length > 0)
|
||||||
|
|
||||||
|
// 当前会话的流状态(从 store 获取)
|
||||||
|
const currentStreamState = computed(() => {
|
||||||
|
return streamStore.getStreamState(conversationId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// sending 状态与流状态同步
|
||||||
|
const sending = computed(() => {
|
||||||
|
const state = currentStreamState.value
|
||||||
|
return state && state.status === 'streaming'
|
||||||
|
})
|
||||||
|
|
||||||
const sendIcon = `<svg viewBox="0 0 24 24" width="18" height="18" 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>`
|
const sendIcon = `<svg viewBox="0 0 24 24" width="18" height="18" 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>`
|
||||||
|
|
||||||
function autoResize() {
|
function autoResize() {
|
||||||
|
|
@ -138,16 +151,13 @@ const loadMessages = async () => {
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
messages.value = res.data.messages || []
|
messages.value = res.data.messages || []
|
||||||
if (messages.value.length > 0) {
|
if (messages.value.length > 0) {
|
||||||
// 优先使用服务器返回的标题,否则用用户的第一条提问
|
|
||||||
if (res.data.title) {
|
if (res.data.title) {
|
||||||
conversationTitle.value = res.data.title
|
conversationTitle.value = res.data.title
|
||||||
} else if (res.data.first_message) {
|
} else if (res.data.first_message) {
|
||||||
conversationTitle.value = res.data.first_message
|
conversationTitle.value = res.data.first_message
|
||||||
} else {
|
} else {
|
||||||
// 查找用户的第一条消息作为标题
|
|
||||||
const userMsg = messages.value.find(m => m.role === 'user')
|
const userMsg = messages.value.find(m => m.role === 'user')
|
||||||
if (userMsg) {
|
if (userMsg) {
|
||||||
// 截取前30个字符作为标题
|
|
||||||
conversationTitle.value = userMsg.content.slice(0, 30) + (userMsg.content.length > 30 ? '...' : '')
|
conversationTitle.value = userMsg.content.slice(0, 30) + (userMsg.content.length > 30 ? '...' : '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -161,7 +171,6 @@ const loadMessages = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载启用的工具列表
|
|
||||||
const loadEnabledTools = async () => {
|
const loadEnabledTools = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await toolsAPI.list()
|
const res = await toolsAPI.list()
|
||||||
|
|
@ -188,16 +197,15 @@ const sendMessage = async () => {
|
||||||
|
|
||||||
const content = inputMessage.value.trim()
|
const content = inputMessage.value.trim()
|
||||||
inputMessage.value = ''
|
inputMessage.value = ''
|
||||||
sending.value = true
|
|
||||||
|
|
||||||
// 清空输入框
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
autoResize()
|
autoResize()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 添加用户消息
|
// 添加用户消息
|
||||||
|
const userMsgId = 'user-' + Date.now()
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
id: Date.now(),
|
id: userMsgId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: content,
|
content: content,
|
||||||
text: content,
|
text: content,
|
||||||
|
|
@ -207,71 +215,15 @@ const sendMessage = async () => {
|
||||||
})
|
})
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
|
|
||||||
// 初始化流式消息
|
// 使用 StreamManager 发送流式请求
|
||||||
streamingMessage.value = {
|
await streamManager.startStream(
|
||||||
id: Date.now() + 1,
|
conversationId.value,
|
||||||
role: 'assistant',
|
|
||||||
process_steps: [],
|
|
||||||
created_at: new Date().toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE 流式请求
|
|
||||||
messagesAPI.sendStream(
|
|
||||||
{
|
|
||||||
conversation_id: conversationId.value,
|
|
||||||
content,
|
|
||||||
enabled_tools: enabledTools.value // 传递启用的工具名称列表
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
onProcessStep: (step) => {
|
conversation_id: conversationId.value,
|
||||||
autoScroll.value = true // 流式开始时启用自动滚动
|
content,
|
||||||
if (!streamingMessage.value) return
|
enabled_tools: enabledTools.value
|
||||||
// 按 id 更新或追加步骤
|
},
|
||||||
const idx = streamingMessage.value.process_steps.findIndex(s => s.id === step.id)
|
userMsgId
|
||||||
if (idx >= 0) {
|
|
||||||
streamingMessage.value.process_steps[idx] = step
|
|
||||||
} else {
|
|
||||||
streamingMessage.value.process_steps.push(step)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDone: (data) => {
|
|
||||||
// 完成,添加到消息列表
|
|
||||||
autoScroll.value = true
|
|
||||||
if (streamingMessage.value) {
|
|
||||||
messages.value.push({
|
|
||||||
...streamingMessage.value,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
token_count: data.token_count,
|
|
||||||
usage: data.usage
|
|
||||||
})
|
|
||||||
|
|
||||||
// 如果标题为空,自动用第一条用户消息作为标题
|
|
||||||
if (!conversationTitle.value || conversationTitle.value === '新对话') {
|
|
||||||
const userMsg = messages.value.find(m => m.role === 'user')
|
|
||||||
if (userMsg) {
|
|
||||||
conversationTitle.value = userMsg.content.slice(0, 30) + (userMsg.content.length > 30 ? '...' : '')
|
|
||||||
// 调用 API 更新标题
|
|
||||||
conversationsAPI.update(conversationId.value, { title: conversationTitle.value })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
streamingMessage.value = null
|
|
||||||
}
|
|
||||||
sending.value = false
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error('Stream error:', error)
|
|
||||||
if (streamingMessage.value) {
|
|
||||||
streamingMessage.value.process_steps.push({
|
|
||||||
id: 'error-' + Date.now(),
|
|
||||||
index: streamingMessage.value.process_steps.length,
|
|
||||||
type: 'error',
|
|
||||||
content: `错误: ${error}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
sending.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
|
|
@ -283,32 +235,28 @@ const scrollToBottom = () => {
|
||||||
if (messagesContainer.value) {
|
if (messagesContainer.value) {
|
||||||
messagesContainer.value.scrollTo({
|
messagesContainer.value.scrollTo({
|
||||||
top: messagesContainer.value.scrollHeight,
|
top: messagesContainer.value.scrollHeight,
|
||||||
behavior: streamingMessage.value ? 'instant' : 'smooth'
|
behavior: 'instant'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理滚动事件,检测用户是否手动滚动
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (!messagesContainer.value) return
|
if (!messagesContainer.value) return
|
||||||
const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value
|
const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value
|
||||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight
|
const distanceToBottom = scrollHeight - scrollTop - clientHeight
|
||||||
// 距离底部超过50px时停止自动跟随
|
|
||||||
autoScroll.value = distanceToBottom < 50
|
autoScroll.value = distanceToBottom < 50
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听流式消息变化,自动滚动
|
// 监听流状态变化,自动滚动
|
||||||
watch(() => streamingMessage.value?.process_steps?.length, () => {
|
watch(
|
||||||
if (streamingMessage.value) {
|
() => currentStreamState.value?.process_steps?.length,
|
||||||
scrollToBottom()
|
() => {
|
||||||
|
if (currentStreamState.value) {
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
const formatTime = (time) => {
|
|
||||||
if (!time) return ''
|
|
||||||
return new Date(time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听对话 ID 变化,重新加载消息
|
// 监听对话 ID 变化,重新加载消息
|
||||||
watch(() => route.params.id, (newId) => {
|
watch(() => route.params.id, (newId) => {
|
||||||
|
|
@ -316,13 +264,75 @@ watch(() => route.params.id, (newId) => {
|
||||||
conversationId.value = newId
|
conversationId.value = newId
|
||||||
loadMessages()
|
loadMessages()
|
||||||
loadEnabledTools()
|
loadEnabledTools()
|
||||||
|
// 重新设置 watch 监听当前会话的流状态
|
||||||
|
setupStreamWatch()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 设置流状态监听
|
||||||
|
let unwatchStream = null
|
||||||
|
const setupStreamWatch = () => {
|
||||||
|
// 取消之前的监听
|
||||||
|
if (unwatchStream) {
|
||||||
|
unwatchStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
unwatchStream = watch(
|
||||||
|
() => streamStore.getStreamState(conversationId.value),
|
||||||
|
(state) => {
|
||||||
|
if (!state) return
|
||||||
|
|
||||||
|
if (state.status === 'done') {
|
||||||
|
// 流完成,添加到消息列表
|
||||||
|
autoScroll.value = true
|
||||||
|
const completedMessage = {
|
||||||
|
id: state.id,
|
||||||
|
role: 'assistant',
|
||||||
|
process_steps: state.process_steps,
|
||||||
|
token_count: state.token_count,
|
||||||
|
usage: state.usage,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
messages.value.push(completedMessage)
|
||||||
|
|
||||||
|
// 更新标题
|
||||||
|
if (!conversationTitle.value || conversationTitle.value === '新对话') {
|
||||||
|
const userMsg = messages.value.find(m => m.role === 'user')
|
||||||
|
if (userMsg) {
|
||||||
|
conversationTitle.value = userMsg.content.slice(0, 30) + (userMsg.content.length > 30 ? '...' : '')
|
||||||
|
conversationsAPI.update(conversationId.value, { title: conversationTitle.value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除流状态
|
||||||
|
streamStore.clearStream(conversationId.value)
|
||||||
|
} else if (state.status === 'error') {
|
||||||
|
// 流错误
|
||||||
|
console.error('Stream error:', state.error)
|
||||||
|
streamStore.clearStream(conversationId.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadMessages()
|
loadMessages()
|
||||||
loadEnabledTools()
|
loadEnabledTools()
|
||||||
|
setupStreamWatch()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 组件卸载时取消监听
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unwatchStream) {
|
||||||
|
unwatchStream()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatTime = (time) => {
|
||||||
|
if (!time) return ''
|
||||||
|
return new Date(time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,11 @@
|
||||||
<span class="conv-item-time">{{ formatDate(c.updated_at) }}</span>
|
<span class="conv-item-time">{{ formatDate(c.updated_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="conv-item-meta">
|
<div class="conv-item-meta">
|
||||||
<span class="conv-item-model">{{ c.model || '-' }}</span>
|
<span class="conv-item-model">
|
||||||
|
{{ c.model || '-' }}
|
||||||
|
<!-- 显示活跃流指示器 -->
|
||||||
|
<span v-if="hasActiveStream(c.id)" class="streaming-indicator" title="正在生成中"></span>
|
||||||
|
</span>
|
||||||
<div class="conv-item-actions" @click.stop>
|
<div class="conv-item-actions" @click.stop>
|
||||||
<button @click="editTitle(c)" class="btn-icon" title="重命名">
|
<button @click="editTitle(c)" class="btn-icon" title="重命名">
|
||||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
|
@ -63,7 +67,7 @@
|
||||||
<div class="spinner-small"></div>
|
<div class="spinner-small"></div>
|
||||||
<span>加载中...</span>
|
<span>加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="convMessages.length || streamingMessage">
|
<div v-else-if="convMessages.length || currentStreamState">
|
||||||
<!-- 历史消息 -->
|
<!-- 历史消息 -->
|
||||||
<div v-for="msg in convMessages" :key="msg.id" class="chat-message" :class="msg.role" :data-msg-id="msg.id">
|
<div v-for="msg in convMessages" :key="msg.id" class="chat-message" :class="msg.role" :data-msg-id="msg.id">
|
||||||
<div class="message-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
|
<div class="message-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
|
||||||
|
|
@ -77,13 +81,13 @@
|
||||||
<div v-else class="message-text" v-html="renderMsgContent(msg)"></div>
|
<div v-else class="message-text" v-html="renderMsgContent(msg)"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 流式消息 -->
|
<!-- 流式消息 - 使用 store 中的状态 -->
|
||||||
<div v-if="streamingMessage" class="chat-message assistant streaming">
|
<div v-if="currentStreamState" class="chat-message assistant streaming">
|
||||||
<div class="message-avatar">🤖</div>
|
<div class="message-avatar">🤖</div>
|
||||||
<div class="message-content">
|
<div class="message-content">
|
||||||
<ProcessBlock
|
<ProcessBlock
|
||||||
:process-steps="streamingMessage.process_steps"
|
:process-steps="currentStreamState.process_steps"
|
||||||
:streaming="true"
|
:streaming="currentStreamState.status === 'streaming'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -160,11 +164,15 @@
|
||||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { conversationsAPI, providersAPI, messagesAPI, toolsAPI } from '../utils/api.js'
|
import { conversationsAPI, providersAPI, messagesAPI, toolsAPI } from '../utils/api.js'
|
||||||
|
import { streamManager } from '../utils/streamManager.js'
|
||||||
|
import { useStreamStore } from '../utils/streamStore.js'
|
||||||
import { renderMarkdown } from '../utils/markdown.js'
|
import { renderMarkdown } from '../utils/markdown.js'
|
||||||
import ProcessBlock from '../components/ProcessBlock.vue'
|
import ProcessBlock from '../components/ProcessBlock.vue'
|
||||||
import MessageNav from '../components/MessageNav.vue'
|
import MessageNav from '../components/MessageNav.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const streamStore = useStreamStore()
|
||||||
|
|
||||||
const list = ref([])
|
const list = ref([])
|
||||||
const providers = ref([])
|
const providers = ref([])
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
|
|
@ -179,10 +187,20 @@ const selectedId = ref(null)
|
||||||
const selectedConv = ref(null)
|
const selectedConv = ref(null)
|
||||||
const convMessages = ref([])
|
const convMessages = ref([])
|
||||||
const loadingMessages = ref(false)
|
const loadingMessages = ref(false)
|
||||||
const enabledTools = ref([]) // 启用的工具名称列表
|
const enabledTools = ref([])
|
||||||
|
|
||||||
const totalPages = computed(() => Math.ceil(total.value / pageSize))
|
const totalPages = computed(() => Math.ceil(total.value / pageSize))
|
||||||
|
|
||||||
|
// 当前会话的流状态
|
||||||
|
const currentStreamState = computed(() => {
|
||||||
|
return streamStore.getStreamState(selectedId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查指定会话是否有活跃流
|
||||||
|
const hasActiveStream = (convId) => {
|
||||||
|
return streamStore.hasActiveStream(convId)
|
||||||
|
}
|
||||||
|
|
||||||
// 加载启用的工具列表
|
// 加载启用的工具列表
|
||||||
const loadEnabledTools = async () => {
|
const loadEnabledTools = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -228,16 +246,22 @@ const selectConv = async (c) => {
|
||||||
selectedId.value = c.id
|
selectedId.value = c.id
|
||||||
selectedConv.value = c
|
selectedConv.value = c
|
||||||
await fetchConvMessages(c.id)
|
await fetchConvMessages(c.id)
|
||||||
|
// 设置流状态监听
|
||||||
|
setupStreamWatch()
|
||||||
}
|
}
|
||||||
|
|
||||||
const newMessage = ref('')
|
const newMessage = ref('')
|
||||||
const sending = ref(false)
|
|
||||||
const messagesContainer = ref(null)
|
const messagesContainer = ref(null)
|
||||||
const streamingMessage = ref(null)
|
|
||||||
const activeMessageId = ref(null)
|
const activeMessageId = ref(null)
|
||||||
let scrollObserver = null
|
let scrollObserver = null
|
||||||
const observedElements = new Set()
|
const observedElements = new Set()
|
||||||
|
|
||||||
|
// sending 状态与流状态同步
|
||||||
|
const sending = computed(() => {
|
||||||
|
const state = streamStore.getStreamState(selectedId.value)
|
||||||
|
return state && state.status === 'streaming'
|
||||||
|
})
|
||||||
|
|
||||||
// 初始化 IntersectionObserver 来跟踪可见消息
|
// 初始化 IntersectionObserver 来跟踪可见消息
|
||||||
const initScrollObserver = () => {
|
const initScrollObserver = () => {
|
||||||
if (!messagesContainer.value) return
|
if (!messagesContainer.value) return
|
||||||
|
|
@ -320,20 +344,21 @@ const scrollToMessageById = (msgId) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(convMessages, () => {
|
// 自动滚动到底部
|
||||||
|
const scrollToBottom = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (messagesContainer.value) {
|
if (messagesContainer.value) {
|
||||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(convMessages, () => {
|
||||||
|
scrollToBottom()
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
watch(() => streamingMessage.value?.process_steps?.length, () => {
|
watch(() => currentStreamState.value?.process_steps?.length, () => {
|
||||||
nextTick(() => {
|
scrollToBottom()
|
||||||
if (messagesContainer.value) {
|
|
||||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 渲染消息内容(Markdown)
|
// 渲染消息内容(Markdown)
|
||||||
|
|
@ -343,77 +368,69 @@ const renderMsgContent = (msg) => {
|
||||||
return renderMarkdown(content)
|
return renderMarkdown(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置流状态监听
|
||||||
|
let unwatchStream = null
|
||||||
|
const setupStreamWatch = () => {
|
||||||
|
// 取消之前的监听
|
||||||
|
if (unwatchStream) {
|
||||||
|
unwatchStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
unwatchStream = watch(
|
||||||
|
() => streamStore.getStreamState(selectedId.value),
|
||||||
|
(state) => {
|
||||||
|
if (!state) return
|
||||||
|
|
||||||
|
if (state.status === 'done') {
|
||||||
|
// 流完成,添加到消息列表
|
||||||
|
const completedMessage = {
|
||||||
|
id: state.id,
|
||||||
|
role: 'assistant',
|
||||||
|
process_steps: state.process_steps,
|
||||||
|
token_count: state.token_count,
|
||||||
|
usage: state.usage,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
convMessages.value.push(completedMessage)
|
||||||
|
|
||||||
|
// 清除流状态
|
||||||
|
streamStore.clearStream(selectedId.value)
|
||||||
|
} else if (state.status === 'error') {
|
||||||
|
// 流错误
|
||||||
|
console.error('Stream error:', state.error)
|
||||||
|
streamStore.clearStream(selectedId.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
if (!newMessage.value.trim() || !selectedConv.value || sending.value) return
|
if (!newMessage.value.trim() || !selectedConv.value || sending.value) return
|
||||||
|
|
||||||
const content = newMessage.value.trim()
|
const content = newMessage.value.trim()
|
||||||
newMessage.value = ''
|
newMessage.value = ''
|
||||||
sending.value = true
|
|
||||||
|
|
||||||
// 添加用户消息到列表
|
// 添加用户消息到列表
|
||||||
|
const userMsgId = 'user-' + Date.now()
|
||||||
const userMsg = {
|
const userMsg = {
|
||||||
id: 'temp-' + Date.now(),
|
id: userMsgId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: content,
|
content: content,
|
||||||
created_at: new Date().toISOString()
|
created_at: new Date().toISOString()
|
||||||
}
|
}
|
||||||
convMessages.value.push(userMsg)
|
convMessages.value.push(userMsg)
|
||||||
|
|
||||||
// 初始化流式消息
|
// 使用 StreamManager 发送流式请求
|
||||||
streamingMessage.value = {
|
await streamManager.startStream(
|
||||||
id: Date.now() + 1,
|
selectedConv.value.id,
|
||||||
role: 'assistant',
|
{
|
||||||
process_steps: [],
|
conversation_id: selectedConv.value.id,
|
||||||
content: '',
|
content,
|
||||||
created_at: new Date().toISOString()
|
enabled_tools: enabledTools.value
|
||||||
}
|
},
|
||||||
|
userMsgId
|
||||||
try {
|
)
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
messagesAPI.sendStream({
|
|
||||||
conversation_id: selectedConv.value.id,
|
|
||||||
content: content,
|
|
||||||
enabled_tools: enabledTools.value // 传递启用的工具名称列表
|
|
||||||
}, {
|
|
||||||
onProcessStep: (step) => {
|
|
||||||
if (!streamingMessage.value) return
|
|
||||||
// 按 id 更新或追加步骤
|
|
||||||
const idx = streamingMessage.value.process_steps.findIndex(s => s.id === step.id)
|
|
||||||
if (idx >= 0) {
|
|
||||||
streamingMessage.value.process_steps[idx] = step
|
|
||||||
} else {
|
|
||||||
streamingMessage.value.process_steps.push(step)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDone: async (data) => {
|
|
||||||
// 将流式消息添加到列表
|
|
||||||
if (streamingMessage.value) {
|
|
||||||
convMessages.value.push({
|
|
||||||
...streamingMessage.value,
|
|
||||||
created_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
streamingMessage.value = null
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
if (streamingMessage.value) {
|
|
||||||
streamingMessage.value.process_steps.push({
|
|
||||||
id: 'error-' + Date.now(),
|
|
||||||
index: streamingMessage.value.process_steps.length,
|
|
||||||
type: 'error',
|
|
||||||
content: error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
reject(new Error(error))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
// 错误已处理
|
|
||||||
} finally {
|
|
||||||
sending.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const createConv = async () => {
|
const createConv = async () => {
|
||||||
|
|
@ -436,6 +453,11 @@ const createConv = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteConv = async (c) => {
|
const deleteConv = async (c) => {
|
||||||
|
// 如果有活跃流,先取消
|
||||||
|
if (hasActiveStream(c.id)) {
|
||||||
|
streamManager.cancelStream(c.id)
|
||||||
|
}
|
||||||
|
|
||||||
if (!confirm(`删除「${c.title || '未命名会话'}」?`)) return
|
if (!confirm(`删除「${c.title || '未命名会话'}」?`)) return
|
||||||
await conversationsAPI.delete(c.id)
|
await conversationsAPI.delete(c.id)
|
||||||
if (selectedId.value === c.id) {
|
if (selectedId.value === c.id) {
|
||||||
|
|
@ -469,6 +491,9 @@ onMounted(() => {
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
scrollObserver?.disconnect()
|
scrollObserver?.disconnect()
|
||||||
|
if (unwatchStream) {
|
||||||
|
unwatchStream()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -493,7 +518,14 @@ onUnmounted(() => {
|
||||||
.conv-item-title { font-size: 0.8rem; font-weight: 500; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-box-orient: vertical; flex: 1; }
|
.conv-item-title { font-size: 0.8rem; font-weight: 500; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-box-orient: vertical; flex: 1; }
|
||||||
.conv-item-time { font-size: 0.65rem; color: var(--text-tertiary); flex-shrink: 0; }
|
.conv-item-time { font-size: 0.65rem; color: var(--text-tertiary); flex-shrink: 0; }
|
||||||
.conv-item-meta { display: flex; justify-content: space-between; align-items: center; }
|
.conv-item-meta { display: flex; justify-content: space-between; align-items: center; }
|
||||||
.conv-item-model { font-size: 0.7rem; color: var(--text-secondary); }
|
.conv-item-model { font-size: 0.7rem; color: var(--text-secondary); display: flex; align-items: center; gap: 4px; }
|
||||||
|
.streaming-indicator {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: #4ade80;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
.conv-item-actions { display: flex; gap: 0.25rem; opacity: 1; }
|
.conv-item-actions { display: flex; gap: 0.25rem; opacity: 1; }
|
||||||
.btn-icon { padding: 0.2rem; background: transparent; border: none; cursor: pointer; font-size: 0.75rem; opacity: 0.6; transition: opacity 0.15s; }
|
.btn-icon { padding: 0.2rem; background: transparent; border: none; cursor: pointer; font-size: 0.75rem; opacity: 0.6; transition: opacity 0.15s; }
|
||||||
.btn-icon:hover { opacity: 1; }
|
.btn-icon:hover { opacity: 1; }
|
||||||
|
|
@ -556,6 +588,7 @@ onUnmounted(() => {
|
||||||
.loading, .empty-sidebar { text-align: center; padding: 2rem; color: var(--text-secondary); font-size: 0.85rem; }
|
.loading, .empty-sidebar { text-align: center; padding: 2rem; color: var(--text-secondary); font-size: 0.85rem; }
|
||||||
.error-msg { text-align: center; padding: 1rem; color: var(--danger-color); background: var(--danger-bg); border-radius: 8px; margin: 1rem; font-size: 0.85rem; }
|
.error-msg { text-align: center; padding: 1rem; color: var(--danger-color); background: var(--danger-bg); border-radius: 8px; margin: 1rem; font-size: 0.85rem; }
|
||||||
.spinner-small { width: 24px; height: 24px; border: 3px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 0.5rem; }
|
.spinner-small { width: 24px; height: 24px; border: 3px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 0.5rem; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
/* 模态框 */
|
/* 模态框 */
|
||||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue