feat(chat): cut over to single SSE endpoint
This commit is contained in:
@@ -204,5 +204,15 @@ logging:
|
||||
# Agent-facing MySQL Tool uses independent logical datasources only.
|
||||
# Production entries are supplied by a dedicated profile and Secret injection.
|
||||
harness:
|
||||
chat:
|
||||
worker-core-pool-size: 2
|
||||
worker-max-pool-size: 8
|
||||
worker-queue-capacity: 64
|
||||
model-core-pool-size: 4
|
||||
model-max-pool-size: 8
|
||||
model-queue-capacity: 32
|
||||
run-timeout: 5m
|
||||
sse-timeout: 5m
|
||||
canonical-ttl: 2h
|
||||
mysql-tools:
|
||||
data-sources: {}
|
||||
|
||||
+119
-294
@@ -2,7 +2,6 @@
|
||||
class SuperBizAgentApp {
|
||||
constructor() {
|
||||
this.apiBaseUrl = 'http://localhost:9900/api';
|
||||
this.currentMode = 'quick'; // 'quick' 或 'stream'
|
||||
this.sessionId = this.generateSessionId();
|
||||
this.isStreaming = false;
|
||||
this.currentChatHistory = []; // 当前对话的消息历史
|
||||
@@ -108,9 +107,6 @@ class SuperBizAgentApp {
|
||||
this.toolsBtn = document.getElementById('toolsBtn');
|
||||
this.toolsMenu = document.getElementById('toolsMenu');
|
||||
this.uploadFileItem = document.getElementById('uploadFileItem');
|
||||
this.modeSelectorBtn = document.getElementById('modeSelectorBtn');
|
||||
this.modeDropdown = document.getElementById('modeDropdown');
|
||||
this.currentModeText = document.getElementById('currentModeText');
|
||||
this.fileInput = document.getElementById('fileInput');
|
||||
|
||||
// 聊天区域元素
|
||||
@@ -137,32 +133,6 @@ class SuperBizAgentApp {
|
||||
this.aiOpsSidebarBtn.addEventListener('click', () => this.triggerAIOps());
|
||||
}
|
||||
|
||||
// 模式选择下拉菜单
|
||||
if (this.modeSelectorBtn) {
|
||||
this.modeSelectorBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleModeDropdown();
|
||||
});
|
||||
}
|
||||
|
||||
// 下拉菜单项点击
|
||||
const dropdownItems = document.querySelectorAll('.dropdown-item');
|
||||
dropdownItems.forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
const mode = item.getAttribute('data-mode');
|
||||
this.selectMode(mode);
|
||||
this.closeModeDropdown();
|
||||
});
|
||||
});
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.modeSelectorBtn.contains(e.target) &&
|
||||
!this.modeDropdown.contains(e.target)) {
|
||||
this.closeModeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
// 发送消息
|
||||
if (this.sendButton) {
|
||||
this.sendButton.addEventListener('click', () => this.sendMessage());
|
||||
@@ -271,8 +241,6 @@ class SuperBizAgentApp {
|
||||
this.sessionId = this.generateSessionId();
|
||||
this.updateTraceWorkbenchLink();
|
||||
|
||||
// 重置模式为快速
|
||||
this.currentMode = 'quick';
|
||||
this.updateUI();
|
||||
|
||||
// 重新设置居中样式(确保对话框居中显示)
|
||||
@@ -552,8 +520,10 @@ class SuperBizAgentApp {
|
||||
return;
|
||||
}
|
||||
const payload = sseMessage.data && typeof sseMessage.data === 'object' ? sseMessage.data : {};
|
||||
const sessionId = sseMessage.sessionId || payload.sessionId;
|
||||
const runId = sseMessage.runId || payload.runId;
|
||||
const sessionId = sseMessage.session_id || sseMessage.sessionId
|
||||
|| payload.session_id || payload.sessionId;
|
||||
const runId = sseMessage.run_id || sseMessage.runId
|
||||
|| payload.run_id || payload.runId;
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
@@ -562,66 +532,8 @@ class SuperBizAgentApp {
|
||||
this.rememberTraceTarget(sessionId, runId || null);
|
||||
}
|
||||
|
||||
// 切换模式下拉菜单
|
||||
toggleModeDropdown() {
|
||||
if (this.modeSelectorBtn && this.modeDropdown) {
|
||||
const wrapper = this.modeSelectorBtn.closest('.mode-selector-wrapper');
|
||||
if (wrapper) {
|
||||
wrapper.classList.toggle('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭模式下拉菜单
|
||||
closeModeDropdown() {
|
||||
if (this.modeSelectorBtn && this.modeDropdown) {
|
||||
const wrapper = this.modeSelectorBtn.closest('.mode-selector-wrapper');
|
||||
if (wrapper) {
|
||||
wrapper.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 选择模式
|
||||
selectMode(mode) {
|
||||
if (this.isStreaming) {
|
||||
this.showNotification('请等待当前对话完成后再切换模式', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentMode = mode;
|
||||
this.updateUI();
|
||||
|
||||
const modeNames = {
|
||||
'quick': '快速',
|
||||
'stream': '流式'
|
||||
};
|
||||
|
||||
this.showNotification(`已切换到${modeNames[mode]}模式`, 'info');
|
||||
}
|
||||
|
||||
// 更新UI
|
||||
updateUI() {
|
||||
// 更新模式选择器显示
|
||||
if (this.currentModeText) {
|
||||
const modeNames = {
|
||||
'quick': '快速',
|
||||
'stream': '流式'
|
||||
};
|
||||
this.currentModeText.textContent = modeNames[this.currentMode] || '快速';
|
||||
}
|
||||
|
||||
// 更新下拉菜单选中状态
|
||||
const dropdownItems = document.querySelectorAll('.dropdown-item');
|
||||
dropdownItems.forEach(item => {
|
||||
const mode = item.getAttribute('data-mode');
|
||||
if (mode === this.currentMode) {
|
||||
item.classList.add('active');
|
||||
} else {
|
||||
item.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// 更新发送按钮状态
|
||||
if (this.sendButton) {
|
||||
this.sendButton.disabled = this.isStreaming;
|
||||
@@ -669,11 +581,7 @@ class SuperBizAgentApp {
|
||||
this.updateUI();
|
||||
|
||||
try {
|
||||
if (this.currentMode === 'quick') {
|
||||
await this.sendQuickMessage(message);
|
||||
} else if (this.currentMode === 'stream') {
|
||||
await this.sendStreamMessage(message);
|
||||
}
|
||||
await this.sendChatMessage(message);
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
this.addMessage('assistant', '抱歉,发送消息时出现错误:' + error.message);
|
||||
@@ -689,214 +597,131 @@ class SuperBizAgentApp {
|
||||
}
|
||||
}
|
||||
|
||||
// 发送快速消息(普通对话)
|
||||
async sendQuickMessage(message) {
|
||||
// 添加等待提示消息
|
||||
const loadingMessage = this.addLoadingMessage('正在思考...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiBaseUrl}/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
Id: this.sessionId,
|
||||
Question: message
|
||||
})
|
||||
});
|
||||
async sendChatMessage(message) {
|
||||
const loadingMessage = this.addLoadingMessage('正在处理...');
|
||||
const assistantMessageElement = this.addMessage('assistant', '', true);
|
||||
const response = await fetch(`${this.apiBaseUrl}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ Id: this.sessionId, Question: message })
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`HTTP错误: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP错误: ${response.status}`);
|
||||
const state = { metadata: false, content: false, failure: false, done: false };
|
||||
let fullResponse = '';
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
const renderContent = (event) => {
|
||||
if (state.content || state.failure || !state.metadata
|
||||
|| !event.content_type || !event.payload) {
|
||||
throw new Error('非法 content 事件');
|
||||
}
|
||||
state.content = true;
|
||||
fullResponse = this.renderChatPayload(event.content_type, event.payload);
|
||||
const contentElement = assistantMessageElement.querySelector('.message-content');
|
||||
contentElement.innerHTML = this.renderMarkdown(fullResponse);
|
||||
this.highlightCodeBlocks(contentElement);
|
||||
this.scrollToBottom();
|
||||
};
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[sendQuickMessage] 响应数据:', JSON.stringify(data));
|
||||
|
||||
// 移除等待提示消息
|
||||
if (loadingMessage && loadingMessage.parentNode) {
|
||||
loadingMessage.parentNode.removeChild(loadingMessage);
|
||||
const handleEvent = (frame) => {
|
||||
const lines = frame.split(/\r?\n/);
|
||||
const eventName = lines.find(line => line.startsWith('event:'))?.slice(6).trim();
|
||||
const dataLines = lines.filter(line => line.startsWith('data:'))
|
||||
.map(line => line.slice(5).trim());
|
||||
if (!eventName || dataLines.length === 0) {
|
||||
throw new Error('非法 SSE frame');
|
||||
}
|
||||
|
||||
// 统一响应格式:检查 data.code 或 data.message 判断请求是否成功
|
||||
if (data.code === 200 || data.message === 'success') {
|
||||
// data.data 是 ChatResponse 对象
|
||||
const chatResponse = data.data;
|
||||
|
||||
if (chatResponse && chatResponse.success) {
|
||||
// 保存后端返回的 sessionId/runId,用于 feedback 提交和 Trace 精确定位
|
||||
if (chatResponse.sessionId) {
|
||||
this.lastSessionId = chatResponse.sessionId;
|
||||
this.lastRunId = chatResponse.runId || '';
|
||||
this.rememberTraceTarget(chatResponse.sessionId, chatResponse.runId || null);
|
||||
const event = JSON.parse(dataLines.join('\n'));
|
||||
switch (eventName) {
|
||||
case 'metadata':
|
||||
if (state.metadata || state.content || state.failure || state.done
|
||||
|| !event.session_id || !event.run_id) {
|
||||
throw new Error('非法 metadata 事件');
|
||||
}
|
||||
// 成功:添加实际响应消息(即使 answer 为空也显示)
|
||||
const answer = chatResponse.answer || '(无回复内容)';
|
||||
this.addMessage('assistant', answer);
|
||||
} else if (chatResponse && chatResponse.errorMessage) {
|
||||
// 业务错误
|
||||
throw new Error(chatResponse.errorMessage);
|
||||
} else {
|
||||
// 兜底:尝试显示任何可用内容
|
||||
const fallbackAnswer = chatResponse?.answer || chatResponse?.errorMessage || '服务返回了空内容';
|
||||
this.addMessage('assistant', fallbackAnswer);
|
||||
}
|
||||
} else {
|
||||
// HTTP 成功但业务失败
|
||||
throw new Error(data.message || '请求失败');
|
||||
state.metadata = true;
|
||||
this.rememberRunMetadata(event);
|
||||
break;
|
||||
case 'status':
|
||||
if (!state.metadata || state.content || state.failure || state.done
|
||||
|| !event.code || !event.message) {
|
||||
throw new Error('非法 status 事件');
|
||||
}
|
||||
const statusText = loadingMessage?.querySelector('.loading-message-content span');
|
||||
if (statusText) statusText.textContent = event.message;
|
||||
break;
|
||||
case 'content':
|
||||
renderContent(event);
|
||||
break;
|
||||
case 'failure':
|
||||
if (state.failure || state.content || state.done || !state.metadata
|
||||
|| !event.code || !event.message) {
|
||||
throw new Error('非法 failure 事件');
|
||||
}
|
||||
state.failure = true;
|
||||
fullResponse = event.message;
|
||||
assistantMessageElement.querySelector('.message-content').textContent = event.message;
|
||||
break;
|
||||
case 'done':
|
||||
if (state.done || !state.metadata || (!state.content && !state.failure)
|
||||
|| !['SUCCESS', 'FALLBACK', 'FAILED'].includes(event.outcome)) {
|
||||
throw new Error('非法 done 事件');
|
||||
}
|
||||
state.done = true;
|
||||
if (event.outcome === 'FAILED') {
|
||||
if (!state.failure) throw new Error('FAILED 缺少 failure');
|
||||
} else if (!state.content) {
|
||||
throw new Error('成功终态缺少 content');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`未知 SSE 事件: ${eventName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// 出错时也要移除等待提示消息
|
||||
if (loadingMessage && loadingMessage.parentNode) {
|
||||
loadingMessage.parentNode.removeChild(loadingMessage);
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const frames = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = frames.pop() || '';
|
||||
frames.filter(Boolean).forEach(handleEvent);
|
||||
}
|
||||
throw error;
|
||||
buffer += decoder.decode();
|
||||
if (buffer.trim()) handleEvent(buffer);
|
||||
if (!state.done) throw new Error('SSE 缺少 done 终态');
|
||||
this.handleStreamComplete(assistantMessageElement, fullResponse);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
if (loadingMessage && loadingMessage.parentNode) loadingMessage.parentNode.removeChild(loadingMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送流式消息
|
||||
async sendStreamMessage(message) {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBaseUrl}/chat_stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
Id: this.sessionId,
|
||||
Question: message
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP错误: ${response.status}`);
|
||||
}
|
||||
|
||||
// 创建助手消息元素
|
||||
const assistantMessageElement = this.addMessage('assistant', '', true);
|
||||
let fullResponse = '';
|
||||
|
||||
// 处理流式响应
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let currentEvent = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
// 流结束,使用统一的处理方法
|
||||
this.handleStreamComplete(assistantMessageElement, fullResponse);
|
||||
break;
|
||||
}
|
||||
|
||||
// 解码数据并添加到缓冲区
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// 按行分割处理
|
||||
const lines = buffer.split('\n');
|
||||
// 保留最后一行(可能不完整)
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') continue;
|
||||
|
||||
console.log('[SSE调试] 收到行:', line);
|
||||
|
||||
// 解析SSE格式
|
||||
if (line.startsWith('id:')) {
|
||||
console.log('[SSE调试] 解析到ID');
|
||||
continue;
|
||||
} else if (line.startsWith('event:')) {
|
||||
// 兼容 "event:message" 和 "event: message" 两种格式
|
||||
currentEvent = line.substring(6).trim();
|
||||
console.log('[SSE调试] 解析到事件类型:', currentEvent);
|
||||
// 注意:后端统一使用 "message" 事件名,真正的类型在 data 的 JSON 中
|
||||
continue;
|
||||
} else if (line.startsWith('data:')) {
|
||||
// 兼容 "data:xxx" 和 "data: xxx" 两种格式
|
||||
const rawData = line.substring(5).trim();
|
||||
console.log('[SSE调试] 解析到数据, currentEvent:', currentEvent, ', rawData:', rawData);
|
||||
|
||||
// 兼容旧格式 [DONE] 标记
|
||||
if (rawData === '[DONE]') {
|
||||
// 流结束标记,将内容转换为Markdown渲染
|
||||
this.handleStreamComplete(assistantMessageElement, fullResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理 SSE 数据
|
||||
try {
|
||||
// 尝试解析为 SseMessage 格式的 JSON
|
||||
const sseMessage = JSON.parse(rawData);
|
||||
console.log('[SSE调试] 解析JSON成功:', sseMessage);
|
||||
|
||||
if (sseMessage && typeof sseMessage.type === 'string') {
|
||||
if (sseMessage.type === 'metadata') {
|
||||
this.rememberRunMetadata(sseMessage);
|
||||
} else if (sseMessage.type === 'content') {
|
||||
const content = sseMessage.data || '';
|
||||
fullResponse += content;
|
||||
console.log('[SSE调试] 添加内容:', content);
|
||||
|
||||
// 实时渲染 Markdown
|
||||
if (assistantMessageElement) {
|
||||
const messageContent = assistantMessageElement.querySelector('.message-content');
|
||||
messageContent.innerHTML = this.renderMarkdown(fullResponse);
|
||||
// 高亮代码块
|
||||
this.highlightCodeBlocks(messageContent);
|
||||
this.scrollToBottom();
|
||||
}
|
||||
} else if (sseMessage.type === 'done') {
|
||||
console.log('[SSE调试] 收到done标记,流结束');
|
||||
this.handleStreamComplete(assistantMessageElement, fullResponse);
|
||||
return;
|
||||
} else if (sseMessage.type === 'error') {
|
||||
console.error('[SSE调试] 收到错误:', sseMessage.data);
|
||||
if (assistantMessageElement) {
|
||||
const messageContent = assistantMessageElement.querySelector('.message-content');
|
||||
messageContent.innerHTML = this.renderMarkdown('错误: ' + (sseMessage.data || '未知错误'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 不是标准 SseMessage 格式,尝试兼容处理
|
||||
console.log('[SSE调试] 非标准格式,尝试兼容处理');
|
||||
fullResponse += rawData;
|
||||
if (assistantMessageElement) {
|
||||
const messageContent = assistantMessageElement.querySelector('.message-content');
|
||||
messageContent.innerHTML = this.renderMarkdown(fullResponse);
|
||||
this.highlightCodeBlocks(messageContent);
|
||||
this.scrollToBottom();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON 解析失败,尝试兼容旧格式
|
||||
console.log('[SSE调试] JSON解析失败,使用兼容模式:', e.message);
|
||||
if (rawData === '') {
|
||||
fullResponse += '\n';
|
||||
} else {
|
||||
fullResponse += rawData;
|
||||
}
|
||||
|
||||
if (assistantMessageElement) {
|
||||
const messageContent = assistantMessageElement.querySelector('.message-content');
|
||||
messageContent.innerHTML = this.renderMarkdown(fullResponse);
|
||||
this.highlightCodeBlocks(messageContent);
|
||||
this.scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
renderChatPayload(contentType, payload) {
|
||||
if (contentType === 'SYSTEM_CHAT') return payload.answer || '';
|
||||
if (contentType === 'KNOWLEDGE_ANSWER') {
|
||||
const references = (payload.references || []).map(ref => `- ${ref.title || ref.document_id}`).join('\n');
|
||||
return [payload.answer || '', references ? `\n\n参考资料:\n${references}` : ''].join('');
|
||||
}
|
||||
if (contentType === 'SAFE_FALLBACK') {
|
||||
return payload.fallback?.message || '当前证据不足,无法确认根因';
|
||||
}
|
||||
if (contentType === 'DIAGNOSIS_REPORT') {
|
||||
const report = payload.report || {};
|
||||
const sections = [];
|
||||
if (report.conclusion?.text) sections.push(`## 结论\n${report.conclusion.text}`);
|
||||
if (report.analysis?.length) sections.push(`## 分析\n${report.analysis.map(item => `- ${item.text}`).join('\n')}`);
|
||||
if (report.action_plan?.length) sections.push(`## 行动计划\n${report.action_plan.map(item => `- ${item.action}`).join('\n')}`);
|
||||
if (report.recommendations?.length) sections.push(`## 建议\n${report.recommendations.map(item => `- ${item.text}`).join('\n')}`);
|
||||
if (report.limitations?.scope) sections.push(`## 限制\n${report.limitations.scope}`);
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
throw new Error(`未知 content_type: ${contentType}`);
|
||||
}
|
||||
|
||||
// 添加消息到聊天界面
|
||||
|
||||
@@ -95,30 +95,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-actions">
|
||||
<div class="mode-selector-wrapper">
|
||||
<button class="mode-selector-btn" id="modeSelectorBtn">
|
||||
<span id="currentModeText">快速</span>
|
||||
<svg class="dropdown-arrow" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="mode-dropdown" id="modeDropdown">
|
||||
<div class="dropdown-header">选择对话方式</div>
|
||||
<div class="dropdown-item active" data-mode="quick">
|
||||
<div class="dropdown-item-main">
|
||||
<span>快速</span>
|
||||
<span class="badge-new">新</span>
|
||||
</div>
|
||||
<div class="dropdown-item-sub">快速对话</div>
|
||||
</div>
|
||||
<div class="dropdown-item" data-mode="stream">
|
||||
<div class="dropdown-item-main">
|
||||
<span>流式</span>
|
||||
</div>
|
||||
<div class="dropdown-item-sub">流式对话</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="send-btn-circle" id="sendButton" title="发送">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22 2L11 13M22 2L15 22L11 13M22 2L2 9L11 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
@@ -735,108 +735,6 @@ body {
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.mode-selector-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mode-selector-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #5f6368;
|
||||
font-weight: 400;
|
||||
transition: color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mode-selector-btn:hover {
|
||||
color: #202124;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.mode-selector-wrapper.active .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.mode-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dadce0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 10px rgba(60, 64, 67, 0.15);
|
||||
min-width: 200px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(8px);
|
||||
transition: all 0.2s;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.mode-selector-wrapper.active .mode-dropdown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
padding: 12px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #5f6368;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid #dadce0;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #f1f3f4;
|
||||
}
|
||||
|
||||
.dropdown-item.active {
|
||||
background: rgba(26, 115, 232, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #202124;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dropdown-item-sub {
|
||||
font-size: 12px;
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
.badge-new {
|
||||
background: #1a73e8;
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.send-btn-circle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -1146,22 +1044,6 @@ body {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mode-selector-wrapper {
|
||||
order: -1;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mode-selector-btn {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mode-dropdown {
|
||||
width: 100%;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 反馈栏 */
|
||||
|
||||
Reference in New Issue
Block a user