mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-02 14:20:10 +08:00
feat: 新增 AI 助手页面 + 图片识别功能 + 更新宣传素材
- 新增 AI 助手页面 (assistant.js/assistant.css/assistant.rs) - 新增图片识别截图 (13.png) 并加入官网和 README - 更新宣传视频 (promo-web.mp4) 含 AI 助手+工具调用+图片识别场景 - 更新视频封面 (video-cover.png/video-cover-light.png) 突出 AI 助手 - 更新 AI 助手演示 GIF (ai-assistant-demo.gif) 作为 README 首图 - 更新功能矩阵 GIF (feature-showcase.gif) AI 助手为 star 卡片 - 官网新增图片识别 showcase 区块 - README 新增图片识别特性和截图 - 视频封面改用专业设计版本 - 演示视频时长 badge 更新为 50 秒
This commit is contained in:
@@ -10,6 +10,7 @@ const NAV_ITEMS_FULL = [
|
||||
section: '概览',
|
||||
items: [
|
||||
{ route: '/dashboard', label: '仪表盘', icon: 'dashboard' },
|
||||
{ route: '/assistant', label: 'AI 助手', icon: 'assistant' },
|
||||
{ route: '/chat', label: '实时聊天', icon: 'chat' },
|
||||
{ route: '/services', label: '服务管理', icon: 'services' },
|
||||
{ route: '/logs', label: '日志查看', icon: 'logs' },
|
||||
@@ -38,8 +39,8 @@ const NAV_ITEMS_FULL = [
|
||||
{
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/about', label: '关于', icon: 'about' },
|
||||
{ route: '/chat-debug', label: '系统诊断', icon: 'debug' },
|
||||
{ route: '/about', label: '关于', icon: 'about' },
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -49,6 +50,7 @@ const NAV_ITEMS_SETUP = [
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/setup', label: '初始设置', icon: 'setup' },
|
||||
{ route: '/assistant', label: 'AI 助手', icon: 'assistant' },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -60,8 +62,8 @@ const NAV_ITEMS_SETUP = [
|
||||
{
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/about', label: '关于', icon: 'about' },
|
||||
{ route: '/chat-debug', label: '系统诊断', icon: 'debug' },
|
||||
{ route: '/about', label: '关于', icon: 'about' },
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -78,6 +80,7 @@ const ICONS = {
|
||||
memory: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>',
|
||||
extensions: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>',
|
||||
about: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
|
||||
assistant: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/><path d="M18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/></svg>',
|
||||
debug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
|
||||
}
|
||||
|
||||
|
||||
@@ -17,18 +17,24 @@ const KEYWORDS = new Set([
|
||||
|
||||
function highlightCode(code, lang) {
|
||||
const escaped = escapeHtml(code)
|
||||
// Two-phase: mark with control chars first, convert to HTML last
|
||||
// Prevents keyword regex from matching "class" inside <span class="..."> attributes
|
||||
const S = '\x02', E = '\x03'
|
||||
const CLS = ['hl-number','hl-comment','hl-string','hl-type','hl-func','hl-keyword']
|
||||
return escaped
|
||||
.replace(/\b(\d+\.?\d*)\b/g, '<span class="hl-number">$1</span>')
|
||||
.replace(/(\/\/.*$|#.*$)/gm, '<span class="hl-comment">$1</span>')
|
||||
.replace(/(\/\*[\s\S]*?\*\/)/g, '<span class="hl-comment">$1</span>')
|
||||
.replace(/\b(\d+\.?\d*)\b/g, `${S}0${E}$1${S}c${E}`)
|
||||
.replace(/(\/\/.*$|#.*$)/gm, `${S}1${E}$1${S}c${E}`)
|
||||
.replace(/(\/\*[\s\S]*?\*\/)/g, `${S}1${E}$1${S}c${E}`)
|
||||
.replace(/("(?:[^&]|&(?!quot;))*?"|'(?:[^&]|&(?!#x27;))*?'|`[^`]*`)/g,
|
||||
'<span class="hl-string">$1</span>')
|
||||
`${S}2${E}$1${S}c${E}`)
|
||||
.replace(/\b([A-Z][a-zA-Z0-9_]*)\b/g, (m, w) =>
|
||||
KEYWORDS.has(w) ? m : `<span class="hl-type">${w}</span>`)
|
||||
KEYWORDS.has(w) ? m : `${S}3${E}${w}${S}c${E}`)
|
||||
.replace(/\b(\w+)(?=\s*\()/g, (m, w) =>
|
||||
KEYWORDS.has(w) ? m : `<span class="hl-func">${w}</span>`)
|
||||
KEYWORDS.has(w) ? m : `${S}4${E}${w}${S}c${E}`)
|
||||
.replace(/\b(\w+)\b/g, (m, w) =>
|
||||
KEYWORDS.has(w) ? `<span class="hl-keyword">${w}</span>` : m)
|
||||
KEYWORDS.has(w) ? `${S}5${E}${w}${S}c${E}` : m)
|
||||
.replace(/\x02([0-5])\x03/g, (_, i) => `<span class="${CLS[+i]}">`)
|
||||
.replace(/\x02c\x03/g, '</span>')
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
|
||||
@@ -14,6 +14,7 @@ const NO_MOCK_CMDS = new Set([
|
||||
'write_memory_file', 'delete_memory_file',
|
||||
'set_npm_registry', 'reload_gateway', 'restart_gateway',
|
||||
'auto_pair_device',
|
||||
'assistant_exec', 'assistant_write_file',
|
||||
])
|
||||
|
||||
// 预加载 Tauri invoke,避免每次 API 调用都做动态 import
|
||||
@@ -236,6 +237,19 @@ function mockInvoke(cmd, args) {
|
||||
cftunnel_action: () => true,
|
||||
get_cftunnel_logs: () => '2026-02-26 13:29:01 [INFO] Tunnel started\n2026-02-26 13:30:00 [INFO] Connection healthy',
|
||||
get_clawapp_status: () => ({ running: true, pid: 7752, port: 3210, url: 'http://localhost:3210' }),
|
||||
// AI 助手工具
|
||||
assistant_exec: ({ command }) => `[mock] 执行: ${command}\n这是模拟输出`,
|
||||
assistant_read_file: ({ path }) => `[mock] 文件内容: ${path}\n# 示例文件\n这是模拟文件内容`,
|
||||
assistant_write_file: ({ path, content }) => `已写入 ${path} (${content.length} 字节)`,
|
||||
assistant_list_dir: ({ path }) => '[DIR] src/\n[DIR] docs/\n[FILE] README.md (1024 bytes)\n[FILE] package.json (512 bytes)',
|
||||
assistant_system_info: () => `OS: ${navigator.platform.includes('Win') ? 'windows' : navigator.platform.includes('Mac') ? 'macos' : 'linux'}\nArch: x86_64\nHome: ${navigator.platform.includes('Win') ? 'C:\\Users\\user' : '/Users/user'}\nHostname: mock-host\nShell: ${navigator.platform.includes('Win') ? 'powershell / cmd' : 'zsh'}\nPath separator: ${navigator.platform.includes('Win') ? '\\\\' : '/'}`,
|
||||
assistant_list_processes: ({ filter }) => filter ? `Id ProcessName\n-- -----------\n1234 ${filter}\n5678 ${filter}-helper` : 'Id ProcessName\n-- -----------\n1 System\n1234 node\n5678 openclaw',
|
||||
assistant_check_port: ({ port }) => port === 18789 ? `端口 ${port} 已被占用(正在监听)\n占用进程: node` : `端口 ${port} 未被占用(空闲)`,
|
||||
// 数据目录 & 图片存储
|
||||
assistant_ensure_data_dir: () => (navigator.platform.includes('Win') ? 'C:\\Users\\user\\.openclaw\\clawpanel' : '/Users/user/.openclaw/clawpanel'),
|
||||
assistant_save_image: ({ id }) => `/mock/images/${id}.jpg`,
|
||||
assistant_load_image: () => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualzQAAAABJRU5ErkJggg==',
|
||||
assistant_delete_image: () => null,
|
||||
}
|
||||
const fn = mocks[cmd]
|
||||
return fn ? Promise.resolve(fn(args)) : Promise.reject(`未知命令: ${cmd}`)
|
||||
@@ -316,4 +330,19 @@ export const api = {
|
||||
// 设备配对
|
||||
autoPairDevice: () => invoke('auto_pair_device'),
|
||||
checkPairingStatus: () => invoke('check_pairing_status'),
|
||||
|
||||
// AI 助手工具
|
||||
assistantExec: (command, cwd) => invoke('assistant_exec', { command, cwd: cwd || null }),
|
||||
assistantReadFile: (path) => invoke('assistant_read_file', { path }),
|
||||
assistantWriteFile: (path, content) => invoke('assistant_write_file', { path, content }),
|
||||
assistantListDir: (path) => invoke('assistant_list_dir', { path }),
|
||||
assistantSystemInfo: () => invoke('assistant_system_info'),
|
||||
assistantListProcesses: (filter) => invoke('assistant_list_processes', { filter: filter || null }),
|
||||
assistantCheckPort: (port) => invoke('assistant_check_port', { port }),
|
||||
|
||||
// 数据目录 & 图片存储
|
||||
ensureDataDir: () => invoke('assistant_ensure_data_dir'),
|
||||
saveImage: (id, data) => invoke('assistant_save_image', { id, data }),
|
||||
loadImage: (id) => invoke('assistant_load_image', { id }),
|
||||
deleteImage: (id) => invoke('assistant_delete_image', { id }),
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import './style/pages.css'
|
||||
import './style/chat.css'
|
||||
import './style/agents.css'
|
||||
import './style/debug.css'
|
||||
import './style/assistant.css'
|
||||
|
||||
// 初始化主题
|
||||
initTheme()
|
||||
@@ -37,11 +38,19 @@ async function boot() {
|
||||
registerRoute('/memory', () => import('./pages/memory.js'))
|
||||
registerRoute('/extensions', () => import('./pages/extensions.js'))
|
||||
registerRoute('/about', () => import('./pages/about.js'))
|
||||
registerRoute('/assistant', () => import('./pages/assistant.js'))
|
||||
registerRoute('/setup', () => import('./pages/setup.js'))
|
||||
|
||||
renderSidebar(sidebar)
|
||||
initRouter(content)
|
||||
|
||||
// 隐藏启动加载屏
|
||||
const splash = document.getElementById('splash')
|
||||
if (splash) {
|
||||
splash.classList.add('hide')
|
||||
setTimeout(() => splash.remove(), 500)
|
||||
}
|
||||
|
||||
// 后台检测状态,检测完再决定是否跳转 setup
|
||||
detectOpenclawStatus().then(() => {
|
||||
// 重新渲染侧边栏(检测完成后 isOpenclawReady 状态已更新)
|
||||
|
||||
2715
src/pages/assistant.js
Normal file
2715
src/pages/assistant.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -703,21 +703,34 @@ function handleChatEvent(payload) {
|
||||
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
|
||||
appendFilesToEl(_currentAiBubble, _currentAiFiles)
|
||||
}
|
||||
// 添加时间戳 + 耗时
|
||||
// 添加时间戳 + 耗时 + token 消耗
|
||||
const wrapper = _currentAiBubble?.parentElement
|
||||
if (wrapper) {
|
||||
const time = document.createElement('div')
|
||||
time.className = 'msg-time'
|
||||
let timeStr = formatTime(new Date())
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'msg-meta'
|
||||
let parts = [`<span class="msg-time">${formatTime(new Date())}</span>`]
|
||||
// 计算响应耗时
|
||||
let durStr = ''
|
||||
if (payload.durationMs) {
|
||||
timeStr += ` · ${(payload.durationMs / 1000).toFixed(1)}s`
|
||||
durStr = (payload.durationMs / 1000).toFixed(1) + 's'
|
||||
} else if (_streamStartTime) {
|
||||
const dur = ((Date.now() - _streamStartTime) / 1000).toFixed(1)
|
||||
timeStr += ` · ${dur}s`
|
||||
durStr = ((Date.now() - _streamStartTime) / 1000).toFixed(1) + 's'
|
||||
}
|
||||
time.textContent = timeStr
|
||||
wrapper.appendChild(time)
|
||||
if (durStr) parts.push(`<span class="meta-sep">·</span><span class="msg-duration">⏱ ${durStr}</span>`)
|
||||
// token 消耗(从 payload.usage 或 payload.message.usage 提取)
|
||||
const usage = payload.usage || payload.message?.usage || null
|
||||
if (usage) {
|
||||
const inp = usage.input_tokens || usage.prompt_tokens || 0
|
||||
const out = usage.output_tokens || usage.completion_tokens || 0
|
||||
const total = usage.total_tokens || (inp + out)
|
||||
if (total > 0) {
|
||||
let tokenStr = `${total} tokens`
|
||||
if (inp && out) tokenStr = `↑${inp} ↓${out}`
|
||||
parts.push(`<span class="meta-sep">·</span><span class="msg-tokens">${tokenStr}</span>`)
|
||||
}
|
||||
}
|
||||
meta.innerHTML = parts.join('')
|
||||
wrapper.appendChild(meta)
|
||||
}
|
||||
if (_currentAiText || _currentAiImages.length) {
|
||||
saveMessage({
|
||||
@@ -1101,12 +1114,12 @@ function appendUserMessage(text, attachments = [], msgTime) {
|
||||
bubble.appendChild(textNode)
|
||||
}
|
||||
|
||||
const time = document.createElement('div')
|
||||
time.className = 'msg-time'
|
||||
time.textContent = formatTime(msgTime || new Date())
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'msg-meta'
|
||||
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span>`
|
||||
|
||||
wrap.appendChild(bubble)
|
||||
wrap.appendChild(time)
|
||||
wrap.appendChild(meta)
|
||||
_messagesEl.insertBefore(wrap, _typingEl)
|
||||
scrollToBottom()
|
||||
}
|
||||
@@ -1124,12 +1137,12 @@ function appendAiMessage(text, msgTime, images, videos, audios, files) {
|
||||
// 图片点击灯箱
|
||||
bubble.querySelectorAll('img').forEach(img => { if (!img.onclick) img.onclick = () => showLightbox(img.src) })
|
||||
|
||||
const time = document.createElement('div')
|
||||
time.className = 'msg-time'
|
||||
time.textContent = formatTime(msgTime || new Date())
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'msg-meta'
|
||||
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span>`
|
||||
|
||||
wrap.appendChild(bubble)
|
||||
wrap.appendChild(time)
|
||||
wrap.appendChild(meta)
|
||||
_messagesEl.insertBefore(wrap, _typingEl)
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
@@ -149,6 +149,29 @@ function renderSteps(page, { node, cliOk, config }) {
|
||||
</div>
|
||||
`
|
||||
|
||||
// AI 助手入口
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;margin-top:var(--space-md)">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:6px">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg>
|
||||
晴辰助手
|
||||
</div>
|
||||
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);line-height:1.5">
|
||||
遇到安装问题?AI 助手可以帮你诊断和解决。配置好模型后,点击下方按钮${!allOk ? ',当前问题会自动发送给 AI 分析' : ''}。
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-goto-assistant">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg>
|
||||
打开 AI 助手
|
||||
</button>
|
||||
${!allOk ? `<button class="btn btn-primary btn-sm" id="btn-ask-ai-help">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
让 AI 帮我解决
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// 全部就绪 → 进入面板
|
||||
if (allOk) {
|
||||
html += `
|
||||
@@ -159,7 +182,7 @@ function renderSteps(page, { node, cliOk, config }) {
|
||||
}
|
||||
|
||||
stepsEl.innerHTML = html
|
||||
bindEvents(page, nodeOk)
|
||||
bindEvents(page, nodeOk, { node, cliOk, config })
|
||||
}
|
||||
|
||||
function renderInstallSection() {
|
||||
@@ -195,7 +218,37 @@ function renderInstallSection() {
|
||||
`
|
||||
}
|
||||
|
||||
function bindEvents(page, nodeOk) {
|
||||
function buildSetupProblemPrompt({ node, cliOk, config }) {
|
||||
const problems = []
|
||||
if (!node.installed) problems.push('- Node.js 未安装或未检测到')
|
||||
else problems.push(`- Node.js 已安装: ${node.version || '版本未知'}`)
|
||||
if (!cliOk) problems.push('- OpenClaw CLI 未安装')
|
||||
else problems.push('- OpenClaw CLI 已安装')
|
||||
if (!config.installed) problems.push('- 配置文件不存在')
|
||||
else problems.push(`- 配置文件正常: ${config.path || ''}`)
|
||||
|
||||
return `我在安装 OpenClaw 时遇到问题,以下是当前检测状态:
|
||||
|
||||
${problems.join('\n')}
|
||||
|
||||
请帮我分析问题并给出解决步骤。如果需要,请使用工具帮我检查系统环境。`
|
||||
}
|
||||
|
||||
function bindEvents(page, nodeOk, detectState) {
|
||||
// 打开 AI 助手
|
||||
page.querySelector('#btn-goto-assistant')?.addEventListener('click', () => {
|
||||
window.location.hash = '/assistant'
|
||||
})
|
||||
|
||||
// 让 AI 帮我解决(带问题上下文)
|
||||
page.querySelector('#btn-ask-ai-help')?.addEventListener('click', () => {
|
||||
if (detectState) {
|
||||
const prompt = buildSetupProblemPrompt(detectState)
|
||||
sessionStorage.setItem('assistant-auto-prompt', prompt)
|
||||
}
|
||||
window.location.hash = '/assistant'
|
||||
})
|
||||
|
||||
// 进入面板
|
||||
page.querySelector('#btn-enter')?.addEventListener('click', () => {
|
||||
window.location.hash = '/dashboard'
|
||||
|
||||
1522
src/style/assistant.css
Normal file
1522
src/style/assistant.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -128,6 +128,7 @@
|
||||
/* 消息通用 */
|
||||
.msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 85%;
|
||||
animation: msg-in 0.2s ease-out;
|
||||
}
|
||||
@@ -687,15 +688,38 @@
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* 消息时间戳 */
|
||||
.msg-time {
|
||||
/* 消息时间戳 + 元信息 */
|
||||
.msg-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
padding: 0 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.msg-user .msg-meta { justify-content: flex-end; }
|
||||
.msg-ai .msg-meta { justify-content: flex-start; }
|
||||
|
||||
.msg-meta .msg-time {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.msg-meta .msg-tokens {
|
||||
font-size: 10px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.msg-meta .msg-duration {
|
||||
font-size: 10px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.msg-meta .meta-sep {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.4;
|
||||
}
|
||||
.msg-user .msg-time { text-align: right; }
|
||||
.msg-ai .msg-time { text-align: left; }
|
||||
|
||||
/* 消息内图片 */
|
||||
.msg-img {
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
/* Toast 通知 */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: var(--space-lg);
|
||||
top: 56px;
|
||||
right: var(--space-lg);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user